Skip to content

Adds support for Shopify's Managed App Installation including Session Token Exchange #415

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/Actions/AuthenticateShop.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ public function __construct(
/**
* Execution.
*
* Managed App Installs have an `id_token` parameter, whereas oAuth exchange has a `code` query parameter.
*
* @param Request $request The request object.
*
* @return array
Expand All @@ -87,19 +89,22 @@ public function __invoke(Request $request): array
$result = call_user_func(
$this->installShopAction,
ShopDomain::fromNative($request->get('shop')),
$request->query('code')
$request->query('code'),
$request->query('id_token'),
);

if (! $result['completed']) {
// No code, redirect to auth URL
return [$result, false];
}

// Determine if the HMAC is correct
$this->apiHelper->make();
if (! $this->apiHelper->verifyRequest($request->all())) {
// Throw exception, something is wrong
return [$result, null];
if ($request->has('code')) {
// Determine if the HMAC is correct
$this->apiHelper->make();
if (! $this->apiHelper->verifyRequest($request->all())) {
// Throw exception, something is wrong
return [$result, null];
}
}

// Fire the post processing jobs
Expand Down
9 changes: 5 additions & 4 deletions src/Actions/InstallShop.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ public function __construct(
* Execution.
*
* @param ShopDomain $shopDomain The shop ID.
* @param string|null $code The code from Shopify.
* @param string|null $code The code from Shopify (for OAuth).
* @param string|null $idToken The id_token from Shopify (for Managed App Installation).
*
* @return array
*/
public function __invoke(ShopDomain $shopDomain, ?string $code): array
public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $idToken = null): array
{
// Get the shop
$shop = $this->shopQuery->getByDomain($shopDomain, [], true);
Expand All @@ -83,7 +84,7 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): array
AuthMode::OFFLINE();

// If there's no code
if (empty($code)) {
if (empty($code) && empty($idToken)) {
return [
'completed' => false,
'url' => $apiHelper->buildAuthUrl($grantMode, Util::getShopifyConfig('api_scopes', $shop)),
Expand All @@ -98,7 +99,7 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): array
}

// Get the data and set the access token
$data = $apiHelper->getAccessData($code);
$data = $idToken !== null ? $apiHelper->performOfflineTokenExchange($idToken) : $apiHelper->getAccessData($code);
$this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token']));

// Try to get the theme support level, if not, return the default setting
Expand Down
11 changes: 11 additions & 0 deletions src/Contracts/ApiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ public function buildAuthUrl(AuthMode $mode, string $scopes): string;
*/
public function verifyRequest(array $request): bool;

/**
* Exchange a session token for an offline access token.
*
* @link https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange
*
* @param string $token The Session Token from the request.
*
* @return ResponseAccess
*/
public function performOfflineTokenExchange(string $token): ResponseAccess;

/**
* Finish the process by getting the access details from the code.
*
Expand Down
16 changes: 14 additions & 2 deletions src/Http/Middleware/VerifyShopify.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,10 @@ protected function handleInvalidShop(Request $request)
throw new HttpException('Shop is not installed or missing data.', Response::HTTP_FORBIDDEN);
}

return $this->installRedirect(ShopDomain::fromRequest($request));
return $this->installRedirect(
ShopDomain::fromRequest($request),
$request->has('id_token') ? $request->query('id_token') : null
);
}

/**
Expand Down Expand Up @@ -314,11 +317,20 @@ protected function tokenRedirect(Request $request): RedirectResponse
* Redirect to install route.
*
* @param ShopDomainValue $shopDomain The shop domain.
* @param string|null $token The session token (for Managed App Installation).
*
* @return RedirectResponse
*/
protected function installRedirect(ShopDomainValue $shopDomain): RedirectResponse
protected function installRedirect(ShopDomainValue $shopDomain, ?string $token): RedirectResponse
{
if ($token !== null) {
// Managed App Installation.
return Redirect::route(
Util::getShopifyConfig('route_names.authenticate'),
['shop' => $shopDomain->toNative(), 'host' => request('host'), 'locale' => request('locale'), 'id_token' => $token]
);
}

return Redirect::route(
Util::getShopifyConfig('route_names.authenticate'),
['shop' => $shopDomain->toNative(), 'host' => request('host'), 'locale' => request('locale')]
Expand Down
2 changes: 1 addition & 1 deletion src/Messaging/Jobs/AppUninstalledJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function handle(

// Get the shop
$shop = $shopQuery->getByDomain($this->domain);
if ( !$shop ) {
if (!$shop) {
return true;
}
$shopId = $shop->getId();
Expand Down
32 changes: 32 additions & 0 deletions src/Services/ApiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,38 @@ public function verifyRequest(array $request): bool
return $this->api->verifyRequest($request);
}

/**
* {@inheritdoc}
*/
public function performOfflineTokenExchange(string $token): ResponseAccess
{
$data = [
'client_id' => $this->api->getOptions()->getApiKey(),
'client_secret' => $this->api->getOptions()->getApiSecret(),
'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange',
'subject_token' => $token,
'subject_token_type' => 'urn:ietf:params:oauth:token-type:id_token',
'requested_token_type' => 'urn:shopify:params:oauth:token-type:offline-access-token',
];
$response = $this->api->request(
'POST',
'/admin/oauth/access_token',
[
'json' => $data,
]
);

if (isset($response['errors']) && $response['errors'] === true) {
throw new ApiException(
is_string($response['body']) ? $response['body'] : 'Unknown error',
0,
$response['exception']
);
}

return $response['body'];
}

/**
* {@inheritdoc}
*
Expand Down
37 changes: 37 additions & 0 deletions tests/Actions/AuthenticateShopTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,41 @@ public function testRuns(): void
$this->assertTrue($status);
Event::assertDispatched(AppInstalledEvent::class);
}

public function testManagedAppInstall(): void
{
Event::fake();
// Build request
$currentRequest = Request::instance();
$newRequest = $currentRequest->duplicate(
// Query Params
[
'shop' => 'mystore123.myshopify.com',
'host' => 'dfsdf343df3433dd3453453dfdf',
'id_token' => '3d9768c9cc44b8bd66125cb82b6a59a3d835432f560d19b3f79b9fc696ef6396',
'locale' => 'de',
],
// Request Params
null,
// Attributes
null,
// Cookies
null,
// Files
null,
// Server vars
Request::server()
);
Request::swap($newRequest);

// Setup API stub
$this->setApiStub();
ApiStub::stubResponses(['access_token']);

// Run the action
[, $status] = call_user_func($this->action, $newRequest);

$this->assertTrue($status);
Event::assertDispatched(AppInstalledEvent::class);
}
}
31 changes: 31 additions & 0 deletions tests/Actions/InstallShopTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,35 @@ public function testWithCodeSoftDeletedShop(): void
$this->assertNotNull($result['shop_id']);
$this->assertNotSame($currentToken->toNative(), $shop->getAccessToken()->toNative());
}

public function testManagedAppInstall(): void
{
// Setup API stub
$this->setApiStub();
ApiStub::stubResponses(['access_token']);

$this->assertDatabaseMissing(
$this->model,
[
'name' => 'test.myshopify.com',
]
);

$result = call_user_func(
$this->action,
ShopDomain::fromNative('test.myshopify.com'),
null,
'1234'
);

$this->assertDatabaseHas($this->model, [
'id' => $result['shop_id']->toNative(),
'name' => 'test.myshopify.com',
/*
* Password as per the test fixture.
* @see ../../tests/fixtures/access_token.json
*/
'password' => '12345678',
]);
}
}
Loading