From a2b6df124f95d0e199baaee504ac10241709aeae Mon Sep 17 00:00:00 2001 From: Robin Schreiner Date: Fri, 19 Apr 2024 15:43:07 +0200 Subject: [PATCH] Add Shopify token exchange flow and remove old OAuth flow --- composer.json | 3 +- routes/web.php | 3 - .../Controllers/ShopifyAuthController.php | 45 ++----- .../Middleware/EnsureShopifyInstalled.php | 13 +- src/Lib/ShopifyOAuth.php | 117 ++++++++++++++++++ 5 files changed, 137 insertions(+), 44 deletions(-) create mode 100644 src/Lib/ShopifyOAuth.php diff --git a/composer.json b/composer.json index 770d80c..a743779 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,8 @@ "config": { "sort-packages": true, "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true } }, "extra": { diff --git a/routes/web.php b/routes/web.php index ea35927..f40e4ab 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,8 @@ group(function () { Route::fallback(ShopifyFallbackController::class)->middleware('shopify.installed'); - Route::get('/api/auth', [ShopifyAuthController::class, 'initialize']); - Route::get('/api/auth/callback', [ShopifyAuthController::class, 'callback']); }); diff --git a/src/Http/Controllers/ShopifyAuthController.php b/src/Http/Controllers/ShopifyAuthController.php index f9bada8..aa88e98 100644 --- a/src/Http/Controllers/ShopifyAuthController.php +++ b/src/Http/Controllers/ShopifyAuthController.php @@ -2,50 +2,21 @@ namespace Codelayer\LaravelShopifyIntegration\Http\Controllers; -use Codelayer\LaravelShopifyIntegration\Events\ShopifyAppInstalled; -use Codelayer\LaravelShopifyIntegration\Lib\AuthRedirection; -use Codelayer\LaravelShopifyIntegration\Lib\CookieHandler; -use Codelayer\LaravelShopifyIntegration\Lib\EnsureBilling; -use Codelayer\LaravelShopifyIntegration\Models\ShopifySession; -use Illuminate\Http\RedirectResponse; +use Codelayer\LaravelShopifyIntegration\Lib\ShopifyOAuth; use Illuminate\Http\Request; -use Shopify\Auth\OAuth; -use Shopify\Utils; class ShopifyAuthController extends Controller { - public function initialize(Request $request): RedirectResponse + public function authorizeShopify(Request $request): bool { - $shop = Utils::sanitizeShopDomain((string) $request->query('shop')); + try { + ShopifyOAuth::authorizeFromRequest($request); + } catch (\Throwable $e) { + report($e); - // Delete any previously created OAuth sessions that were not completed (don't have an access token) - ShopifySession::where('shop', $shop)->where('access_token', null)->delete(); - - return AuthRedirection::redirect($request); - } - - public function callback(Request $request): RedirectResponse - { - $session = OAuth::callback( - cookies: $request->cookie(), - query: $request->query(), - setCookieFunction: [CookieHandler::class, 'saveShopifyCookie'], - ); - - $host = $request->query('host'); - $shop = Utils::sanitizeShopDomain($request->query('shop')); - - event(new ShopifyAppInstalled($shop)); - - $redirectUrl = Utils::getEmbeddedAppUrl($host); - if (config('shopify-integration.billing.required')) { - [$hasPayment, $confirmationUrl] = EnsureBilling::check($session, config('shopify-integration.billing')); - - if (! $hasPayment) { - $redirectUrl = $confirmationUrl; - } + return false; } - return redirect($redirectUrl); + return true; } } diff --git a/src/Http/Middleware/EnsureShopifyInstalled.php b/src/Http/Middleware/EnsureShopifyInstalled.php index d83912b..105a602 100644 --- a/src/Http/Middleware/EnsureShopifyInstalled.php +++ b/src/Http/Middleware/EnsureShopifyInstalled.php @@ -3,9 +3,10 @@ namespace Codelayer\LaravelShopifyIntegration\Http\Middleware; use Closure; -use Codelayer\LaravelShopifyIntegration\Lib\AuthRedirection; +use Codelayer\LaravelShopifyIntegration\Lib\ShopifyOAuth; use Codelayer\LaravelShopifyIntegration\Models\ShopifySession; use Illuminate\Http\Request; +use Shopify\Context; use Shopify\Utils; class EnsureShopifyInstalled @@ -19,9 +20,15 @@ public function handle(Request $request, Closure $next) { $shop = $request->query('shop') ? Utils::sanitizeShopDomain($request->query('shop')) : null; - $appInstalled = $shop && ShopifySession::where('shop', $shop)->where('access_token', '<>', null)->exists(); + $appInstalled = $shop && ShopifySession::where('shop', $shop)->where('access_token', '<>', null)->where('scope', Context::$SCOPES->toString())->exists(); $isExitingIframe = preg_match('/^ExitIframe/i', $request->path()); - return ($appInstalled || $isExitingIframe) ? $next($request) : AuthRedirection::redirect($request); + if ($appInstalled || $isExitingIframe) { + return $next($request); + } + + ShopifyOAuth::authorizeFromRequest($request); + + return $next($request); } } diff --git a/src/Lib/ShopifyOAuth.php b/src/Lib/ShopifyOAuth.php new file mode 100644 index 0000000..91696eb --- /dev/null +++ b/src/Lib/ShopifyOAuth.php @@ -0,0 +1,117 @@ +toString() + ); + } + + $accessTokenResponse = ShopifyOAuth::exchangeToken( + shop: $shop, + sessionToken: $encodedSessionToken, + requestedTokenType: 'urn:shopify:params:oauth:token-type:offline-access-token', + ); + + $session->setAccessToken($accessTokenResponse->getAccessToken()); + $session->setScope($accessTokenResponse->getScope()); + + $sessionStored = Context::$SESSION_STORAGE->storeSession($session); + + if (! $sessionStored) { + throw new SessionStorageException( + 'OAuth Session could not be saved. Please check your session storage functionality.' + ); + } + + return $session; + } + + /** + * From https://github.com/Shopify/shopify-app-js/blob/ab752293284d344a5e3803271c25e4237e478565/packages/apps/shopify-api/lib/auth/oauth/token-exchange.ts#L27 + * + * @throws HttpRequestException + * @throws \JsonException + * @throws ClientExceptionInterface + * @throws UninitializedContextException + */ + public static function exchangeToken(string $shop, string $sessionToken, string $requestedTokenType): AccessTokenResponse + { + Utils::decodeSessionToken($sessionToken); + + $body = [ + 'client_id' => Context::$API_KEY, + 'client_secret' => Context::$API_SECRET_KEY, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange', + 'subject_token' => $sessionToken, + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'requested_token_type' => $requestedTokenType, + ]; + + $cleanShop = Utils::sanitizeShopDomain($shop); + + $client = new Http($cleanShop); + $response = $client->post(path: '/admin/oauth/access_token', body: $body, headers: [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]); + + if ($response->getStatusCode() !== 200) { + throw new HttpRequestException("Failed to get access token: {$response->getDecodedBody()}"); + } + + $responseBody = $response->getDecodedBody(); + + return new AccessTokenResponse( + accessToken: $responseBody['access_token'], + scope: $responseBody['scope'], + ); + } + + private static function getSessionTokenHeader(Request $request): ?string + { + $authorizationHeader = $request->header('authorization'); + + if (empty($authorizationHeader)) { + return null; + } + + return str_replace('Bearer ', '', $authorizationHeader); + } + + private static function getSessionTokenFromUrlParam(Request $request): ?string + { + return $request->get('id_token'); + } +}