Skip to content

Commit

Permalink
Add Shopify token exchange flow and remove old OAuth flow
Browse files Browse the repository at this point in the history
  • Loading branch information
a47ae committed Apr 19, 2024
1 parent 0106078 commit 553a51a
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 44 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
"pestphp/pest-plugin": true,
"phpstan/extension-installer": true
}
},
"extra": {
Expand Down
3 changes: 0 additions & 3 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
<?php

use Codelayer\LaravelShopifyIntegration\Http\Controllers\ShopifyAuthController;
use Codelayer\LaravelShopifyIntegration\Http\Controllers\ShopifyFallbackController;
use Illuminate\Support\Facades\Route;

Route::middleware(['web'])->group(function () {
Route::fallback(ShopifyFallbackController::class)->middleware('shopify.installed');
Route::get('/api/auth', [ShopifyAuthController::class, 'initialize']);
Route::get('/api/auth/callback', [ShopifyAuthController::class, 'callback']);
});
45 changes: 8 additions & 37 deletions src/Http/Controllers/ShopifyAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
13 changes: 10 additions & 3 deletions src/Http/Middleware/EnsureShopifyInstalled.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)->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);
}
}
117 changes: 117 additions & 0 deletions src/Lib/ShopifyOAuth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace Codelayer\LaravelShopifyIntegration\Lib;

use Illuminate\Http\Request;
use Psr\Http\Client\ClientExceptionInterface;
use Ramsey\Uuid\Uuid;
use Shopify\Auth\AccessTokenResponse;
use Shopify\Auth\OAuth;
use Shopify\Auth\Session;
use Shopify\Clients\Http;
use Shopify\Context;
use Shopify\Exception\HttpRequestException;
use Shopify\Exception\SessionStorageException;
use Shopify\Exception\UninitializedContextException;
use Shopify\Utils;

class ShopifyOAuth extends OAuth
{
public static function authorizeFromRequest(Request $request): ?Session
{
$encodedSessionToken = self::getSessionTokenHeader($request) ?? self::getSessionTokenFromUrlParam($request);
$decodedSessionToken = Utils::decodeSessionToken($encodedSessionToken);

$dest = $decodedSessionToken['dest'];
$shop = parse_url($dest, PHP_URL_HOST);

$cleanShop = Utils::sanitizeShopDomain($shop);

$session = Utils::loadOfflineSession($cleanShop);

if (empty($session)) {
$session = new Session(
id: OAuth::getOfflineSessionId($cleanShop),
shop: $cleanShop,
isOnline: false,
state: Uuid::uuid4()->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');
}
}

0 comments on commit 553a51a

Please sign in to comment.