Skip to content

Commit ddee18f

Browse files
Adds support for Shopify's Managed App Installation including Session Token Exchange (#415)
* Perform Managed Installation Session Token Exchange * Fix: ensure `AppInstalledEvent` event triggers with Managed App Installation flow * Maintain backwards compatibility Restore previous behaviour while also adding in new managed auth flow * Move Managed App Install Token Exchange to new method * Add new tests for managed app install * Code cleanup
1 parent 4e3276b commit ddee18f

File tree

8 files changed

+142
-13
lines changed

8 files changed

+142
-13
lines changed

src/Actions/AuthenticateShop.php

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public function __construct(
7676
/**
7777
* Execution.
7878
*
79+
* Managed App Installs have an `id_token` parameter, whereas oAuth exchange has a `code` query parameter.
80+
*
7981
* @param Request $request The request object.
8082
*
8183
* @return array
@@ -87,19 +89,22 @@ public function __invoke(Request $request): array
8789
$result = call_user_func(
8890
$this->installShopAction,
8991
ShopDomain::fromNative($request->get('shop')),
90-
$request->query('code')
92+
$request->query('code'),
93+
$request->query('id_token'),
9194
);
9295

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

98-
// Determine if the HMAC is correct
99-
$this->apiHelper->make();
100-
if (! $this->apiHelper->verifyRequest($request->all())) {
101-
// Throw exception, something is wrong
102-
return [$result, null];
101+
if ($request->has('code')) {
102+
// Determine if the HMAC is correct
103+
$this->apiHelper->make();
104+
if (! $this->apiHelper->verifyRequest($request->all())) {
105+
// Throw exception, something is wrong
106+
return [$result, null];
107+
}
103108
}
104109

105110
// Fire the post processing jobs

src/Actions/InstallShop.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ public function __construct(
6161
* Execution.
6262
*
6363
* @param ShopDomain $shopDomain The shop ID.
64-
* @param string|null $code The code from Shopify.
64+
* @param string|null $code The code from Shopify (for OAuth).
65+
* @param string|null $idToken The id_token from Shopify (for Managed App Installation).
6566
*
6667
* @return array
6768
*/
68-
public function __invoke(ShopDomain $shopDomain, ?string $code): array
69+
public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $idToken = null): array
6970
{
7071
// Get the shop
7172
$shop = $this->shopQuery->getByDomain($shopDomain, [], true);
@@ -83,7 +84,7 @@ public function __invoke(ShopDomain $shopDomain, ?string $code): array
8384
AuthMode::OFFLINE();
8485

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

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

104105
// Try to get the theme support level, if not, return the default setting

src/Contracts/ApiHelper.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ public function buildAuthUrl(AuthMode $mode, string $scopes): string;
6161
*/
6262
public function verifyRequest(array $request): bool;
6363

64+
/**
65+
* Exchange a session token for an offline access token.
66+
*
67+
* @link https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange
68+
*
69+
* @param string $token The Session Token from the request.
70+
*
71+
* @return ResponseAccess
72+
*/
73+
public function performOfflineTokenExchange(string $token): ResponseAccess;
74+
6475
/**
6576
* Finish the process by getting the access details from the code.
6677
*

src/Http/Middleware/VerifyShopify.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,10 @@ protected function handleInvalidShop(Request $request)
208208
throw new HttpException('Shop is not installed or missing data.', Response::HTTP_FORBIDDEN);
209209
}
210210

211-
return $this->installRedirect(ShopDomain::fromRequest($request));
211+
return $this->installRedirect(
212+
ShopDomain::fromRequest($request),
213+
$request->has('id_token') ? $request->query('id_token') : null
214+
);
212215
}
213216

214217
/**
@@ -314,11 +317,20 @@ protected function tokenRedirect(Request $request): RedirectResponse
314317
* Redirect to install route.
315318
*
316319
* @param ShopDomainValue $shopDomain The shop domain.
320+
* @param string|null $token The session token (for Managed App Installation).
317321
*
318322
* @return RedirectResponse
319323
*/
320-
protected function installRedirect(ShopDomainValue $shopDomain): RedirectResponse
324+
protected function installRedirect(ShopDomainValue $shopDomain, ?string $token): RedirectResponse
321325
{
326+
if ($token !== null) {
327+
// Managed App Installation.
328+
return Redirect::route(
329+
Util::getShopifyConfig('route_names.authenticate'),
330+
['shop' => $shopDomain->toNative(), 'host' => request('host'), 'locale' => request('locale'), 'id_token' => $token]
331+
);
332+
}
333+
322334
return Redirect::route(
323335
Util::getShopifyConfig('route_names.authenticate'),
324336
['shop' => $shopDomain->toNative(), 'host' => request('host'), 'locale' => request('locale')]

src/Messaging/Jobs/AppUninstalledJob.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public function handle(
7272

7373
// Get the shop
7474
$shop = $shopQuery->getByDomain($this->domain);
75-
if ( !$shop ) {
75+
if (!$shop) {
7676
return true;
7777
}
7878
$shopId = $shop->getId();

src/Services/ApiHelper.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,38 @@ public function verifyRequest(array $request): bool
146146
return $this->api->verifyRequest($request);
147147
}
148148

149+
/**
150+
* {@inheritdoc}
151+
*/
152+
public function performOfflineTokenExchange(string $token): ResponseAccess
153+
{
154+
$data = [
155+
'client_id' => $this->api->getOptions()->getApiKey(),
156+
'client_secret' => $this->api->getOptions()->getApiSecret(),
157+
'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange',
158+
'subject_token' => $token,
159+
'subject_token_type' => 'urn:ietf:params:oauth:token-type:id_token',
160+
'requested_token_type' => 'urn:shopify:params:oauth:token-type:offline-access-token',
161+
];
162+
$response = $this->api->request(
163+
'POST',
164+
'/admin/oauth/access_token',
165+
[
166+
'json' => $data,
167+
]
168+
);
169+
170+
if (isset($response['errors']) && $response['errors'] === true) {
171+
throw new ApiException(
172+
is_string($response['body']) ? $response['body'] : 'Unknown error',
173+
0,
174+
$response['exception']
175+
);
176+
}
177+
178+
return $response['body'];
179+
}
180+
149181
/**
150182
* {@inheritdoc}
151183
*

tests/Actions/AuthenticateShopTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,41 @@ public function testRuns(): void
126126
$this->assertTrue($status);
127127
Event::assertDispatched(AppInstalledEvent::class);
128128
}
129+
130+
public function testManagedAppInstall(): void
131+
{
132+
Event::fake();
133+
// Build request
134+
$currentRequest = Request::instance();
135+
$newRequest = $currentRequest->duplicate(
136+
// Query Params
137+
[
138+
'shop' => 'mystore123.myshopify.com',
139+
'host' => 'dfsdf343df3433dd3453453dfdf',
140+
'id_token' => '3d9768c9cc44b8bd66125cb82b6a59a3d835432f560d19b3f79b9fc696ef6396',
141+
'locale' => 'de',
142+
],
143+
// Request Params
144+
null,
145+
// Attributes
146+
null,
147+
// Cookies
148+
null,
149+
// Files
150+
null,
151+
// Server vars
152+
Request::server()
153+
);
154+
Request::swap($newRequest);
155+
156+
// Setup API stub
157+
$this->setApiStub();
158+
ApiStub::stubResponses(['access_token']);
159+
160+
// Run the action
161+
[, $status] = call_user_func($this->action, $newRequest);
162+
163+
$this->assertTrue($status);
164+
Event::assertDispatched(AppInstalledEvent::class);
165+
}
129166
}

tests/Actions/InstallShopTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,35 @@ public function testWithCodeSoftDeletedShop(): void
110110
$this->assertNotNull($result['shop_id']);
111111
$this->assertNotSame($currentToken->toNative(), $shop->getAccessToken()->toNative());
112112
}
113+
114+
public function testManagedAppInstall(): void
115+
{
116+
// Setup API stub
117+
$this->setApiStub();
118+
ApiStub::stubResponses(['access_token']);
119+
120+
$this->assertDatabaseMissing(
121+
$this->model,
122+
[
123+
'name' => 'test.myshopify.com',
124+
]
125+
);
126+
127+
$result = call_user_func(
128+
$this->action,
129+
ShopDomain::fromNative('test.myshopify.com'),
130+
null,
131+
'1234'
132+
);
133+
134+
$this->assertDatabaseHas($this->model, [
135+
'id' => $result['shop_id']->toNative(),
136+
'name' => 'test.myshopify.com',
137+
/*
138+
* Password as per the test fixture.
139+
* @see ../../tests/fixtures/access_token.json
140+
*/
141+
'password' => '12345678',
142+
]);
143+
}
113144
}

0 commit comments

Comments
 (0)