Skip to content

Commit b750029

Browse files
committed
Merge branch 'master' into update/drop-support
2 parents b3f5e02 + a370921 commit b750029

27 files changed

Lines changed: 783 additions & 29 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
composer.lock
55
.php-cs-fixer.cache
66
.phpunit.result.cache
7-
.phpunit.cache
7+
.phpunit.cache/
88

99
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
1010
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
@@ -76,3 +76,4 @@ fabric.properties
7676
.idea/caches/build_file_checksums.ser
7777
/.idea/codeception.xml
7878
/.idea/phpspec.xml
79+

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ For more information, tutorials, etc., please view the project's [wiki](../../wi
4949

5050
For full resources on this package, see the [wiki](../..//wiki).
5151

52+
### Expiring offline access tokens
53+
54+
[Shopify requires expiring offline access tokens](https://shopify.dev/changelog/expiring-offline-access-tokens-required-for-public-apps-april-1-2026) for **new public apps** created on or after April 1, 2026. This package supports them when enabled:
55+
56+
1. Run package migrations so your shops table includes `shopify_offline_refresh_token`, `shopify_offline_access_token_expires_at`, and `shopify_offline_refresh_token_expires_at`.
57+
2. Set `SHOPIFY_EXPIRING_OFFLINE_TOKENS=true` in `.env` (see `expiring_offline_tokens` and `offline_access_token_refresh_skew_seconds` in `config/shopify-app.php`).
58+
3. Keep `APP_KEY` stable: refresh tokens are stored encrypted with Laravel’s encrypter.
59+
60+
Authorization code exchange, session-token exchange, and `refresh_token` grants are handled inside this package (`Osiset\ShopifyApp\Services\ApiHelper` and `OfflineAccessTokenRefresher`), not via `gnikyt/basic-shopify-api` updates. A valid access token is refreshed automatically before `apiHelper()` builds the API session when the offline token is expired or within the configured skew.
61+
62+
If your `User` model overrides `$casts`, merge `datetime` casts for the two `*_expires_at` columns (the `ShopModel` trait uses `mergeCasts` when `initializeShopModel` runs).
63+
64+
Longer term, consider replacing or forking `gnikyt/basic-shopify-api` for REST/Graph traffic if you need an actively maintained HTTP client; expiring offline OAuth is already decoupled from that dependency.
65+
5266
## Issue or request?
5367

5468
If you have found a bug or would like to request a feature for discussion, please use the `ISSUE_TEMPLATE` in this repo when creating your issue. Any issue submitted without this template will be closed.

phpunit.xml.dist

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@
1616
</include>
1717
<exclude>
1818
<directory>src/Contracts/</directory>
19-
<directory>src/Exceptions/</directory>
19+
<file>src/Exceptions/ApiException.php</file>
20+
<file>src/Exceptions/BaseException.php</file>
21+
<file>src/Exceptions/ChargeNotRecurringException.php</file>
22+
<file>src/Exceptions/ChargeNotRecurringOrOnetimeException.php</file>
23+
<file>src/Exceptions/HttpException.php</file>
24+
<file>src/Exceptions/InvalidShopDomainException.php</file>
25+
<file>src/Exceptions/MissingAuthUrlException.php</file>
26+
<file>src/Exceptions/MissingShopDomainException.php</file>
27+
<file>src/Exceptions/SignatureVerificationException.php</file>
2028
<directory>src/Objects/Enums/</directory>
2129
<directory>src/resources/</directory>
2230
<directory>src/Messaging/Events/</directory>

src/Actions/InstallShop.php

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
namespace Osiset\ShopifyApp\Actions;
44

55
use Exception;
6+
use Illuminate\Support\Carbon;
67
use Osiset\ShopifyApp\Contracts\Commands\Shop as IShopCommand;
78
use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery;
9+
use Osiset\ShopifyApp\Contracts\ShopModel as IShopModel;
810
use Osiset\ShopifyApp\Objects\Enums\AuthMode;
911
use Osiset\ShopifyApp\Objects\Enums\ThemeSupportLevel as ThemeSupportLevelEnum;
1012
use Osiset\ShopifyApp\Objects\Values\AccessToken;
@@ -32,9 +34,9 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
3234
}
3335

3436
$apiHelper = $shop->apiHelper();
35-
$grantMode = $shop->hasOfflineAccess() ?
36-
AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) :
37-
AuthMode::OFFLINE();
37+
$grantMode = $shop->hasOfflineAccess()
38+
? AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop))
39+
: AuthMode::OFFLINE();
3840

3941
if (empty($code) && empty($idToken)) {
4042
return [
@@ -50,8 +52,10 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
5052
}
5153

5254
// Get the data and set the access token
53-
$data = $idToken !== null ? $apiHelper->performOfflineTokenExchange($idToken) : $apiHelper->getAccessData($code);
54-
$this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token']));
55+
$data = $idToken !== null
56+
? $apiHelper->performOfflineTokenExchange($idToken)
57+
: $apiHelper->getAccessData($code, $grantMode);
58+
$this->persistShopifyOAuthTokens($shop, $data, $grantMode);
5559

5660
try {
5761
$themeSupportLevel = call_user_func($this->verifyThemeSupport, $shop->getId());
@@ -76,4 +80,36 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
7680
];
7781
}
7882
}
83+
84+
/**
85+
* Persist OAuth tokens and optional expiring-offline metadata.
86+
*
87+
* @param IShopModel $shop
88+
* @param mixed $data
89+
* @param AuthMode $grantMode
90+
*
91+
* @return void
92+
*/
93+
protected function persistShopifyOAuthTokens(IShopModel $shop, $data, AuthMode $grantMode): void
94+
{
95+
$expiringEnabled = Util::getShopifyConfig('expiring_offline_tokens', $shop);
96+
$isOfflineGrant = $grantMode->isSame(AuthMode::OFFLINE());
97+
98+
if ($expiringEnabled && $isOfflineGrant && isset($data['refresh_token'])) {
99+
$this->shopCommand->setAccessToken(
100+
$shop->getId(),
101+
AccessToken::fromNative($data['access_token']),
102+
$data['refresh_token'],
103+
Carbon::now()->addSeconds((int) $data['expires_in']),
104+
Carbon::now()->addSeconds((int) $data['refresh_token_expires_in'])
105+
);
106+
107+
return;
108+
}
109+
110+
$this->shopCommand->setAccessToken(
111+
$shop->getId(),
112+
AccessToken::fromNative($data['access_token'])
113+
);
114+
}
79115
}

src/Contracts/ApiHelper.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,23 @@ public function performOfflineTokenExchange(string $token): ResponseAccess;
7575
/**
7676
* Finish the process by getting the access details from the code.
7777
*
78-
* @param string $code The code from the request.
78+
* @param string $code The code from the request.
79+
* @param AuthMode|null $grantMode Offline vs per-user grant (defaults to offline).
7980
*
8081
* @return ResponseAccess
8182
*/
82-
public function getAccessData(string $code): ResponseAccess;
83+
public function getAccessData(string $code, ?AuthMode $grantMode = null): ResponseAccess;
84+
85+
/**
86+
* Refresh an expiring offline access token using a refresh token.
87+
*
88+
* @link https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
89+
*
90+
* @param string $refreshToken The current offline refresh token.
91+
*
92+
* @return ResponseAccess
93+
*/
94+
public function refreshOfflineAccessToken(string $refreshToken): ResponseAccess;
8395

8496
/**
8597
* Get the script tags for the shop.

src/Contracts/Commands/Shop.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,24 @@ public function setToPlan(ShopIdValue $shopId, PlanIdValue $planId): bool;
3636
/**
3737
* Sets the access token (offline) from Shopify to the shop.
3838
*
39-
* @param ShopIdValue $shopId The shop's ID.
40-
* @param AccessTokenValue $token The token from Shopify Oauth.
39+
* When expiring offline tokens are used, pass the refresh token and expiry
40+
* timestamps; otherwise omit them to clear expiring-offline metadata.
41+
*
42+
* @param ShopIdValue $shopId The shop's ID.
43+
* @param AccessTokenValue $token The token from Shopify OAuth.
44+
* @param string|null $offlineRefreshTokenPlain Decrypted refresh token (stored encrypted).
45+
* @param \DateTimeInterface|null $offlineAccessTokenExpiresAt Access token expiry.
46+
* @param \DateTimeInterface|null $offlineRefreshTokenExpiresAt Refresh token expiry.
4147
*
4248
* @return bool
4349
*/
44-
public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bool;
50+
public function setAccessToken(
51+
ShopIdValue $shopId,
52+
AccessTokenValue $token,
53+
?string $offlineRefreshTokenPlain = null,
54+
$offlineAccessTokenExpiresAt = null,
55+
$offlineRefreshTokenExpiresAt = null
56+
): bool;
4557

4658
/**
4759
* Sets the Online Store 2.0 support level

src/Contracts/ShopModel.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ public function isFreemium(): bool;
7373
*/
7474
public function hasOfflineAccess(): bool;
7575

76+
/**
77+
* Whether the shop has expiring offline token metadata (encrypted refresh token stored).
78+
*
79+
* @return bool
80+
*/
81+
public function hasExpiringOfflineAccess(): bool;
82+
7683
/**
7784
* Get the API helper instance for a shop.
7885
* TODO: Find a better way than using resolve(). However, we can't inject in model constructors.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Osiset\ShopifyApp\Exceptions;
4+
5+
use Exception;
6+
7+
/**
8+
* Thrown when refreshing an expiring offline access token fails (e.g. invalid or expired refresh token).
9+
*/
10+
class OAuthTokenRefreshException extends Exception
11+
{
12+
}

src/Services/ApiHelper.php

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ public function verifyRequest(array $request): bool
151151
*/
152152
public function performOfflineTokenExchange(string $token): ResponseAccess
153153
{
154+
$shop = $this->getShopDomain($this->api->getSession())->toNative();
154155
$data = [
155156
'client_id' => $this->api->getOptions()->getApiKey(),
156157
'client_secret' => $this->api->getOptions()->getApiSecret(),
@@ -159,35 +160,78 @@ public function performOfflineTokenExchange(string $token): ResponseAccess
159160
'subject_token_type' => 'urn:ietf:params:oauth:token-type:id_token',
160161
'requested_token_type' => 'urn:shopify:params:oauth:token-type:offline-access-token',
161162
];
163+
if (Util::getShopifyConfig('expiring_offline_tokens', $shop)) {
164+
$data['expiring'] = 1;
165+
}
166+
167+
return $this->oauthAccessTokenPost($data);
168+
}
169+
170+
/**
171+
* {@inheritdoc}
172+
*
173+
* @codeCoverageIgnore No need to retest.
174+
*/
175+
public function getAccessData(string $code, ?AuthMode $grantMode = null): ResponseAccess
176+
{
177+
$grantMode = $grantMode ?? AuthMode::OFFLINE();
178+
$shop = $this->getShopDomain($this->api->getSession())->toNative();
179+
$useExpiringOffline = Util::getShopifyConfig('expiring_offline_tokens', $shop)
180+
&& $grantMode->isSame(AuthMode::OFFLINE());
181+
182+
if ($useExpiringOffline) {
183+
return $this->oauthAccessTokenPost([
184+
'client_id' => $this->api->getOptions()->getApiKey(),
185+
'client_secret' => $this->api->getOptions()->getApiSecret(),
186+
'code' => $code,
187+
'expiring' => 1,
188+
]);
189+
}
190+
191+
return $this->api->requestAccess($code);
192+
}
193+
194+
/**
195+
* {@inheritdoc}
196+
*/
197+
public function refreshOfflineAccessToken(string $refreshToken): ResponseAccess
198+
{
199+
return $this->oauthAccessTokenPost([
200+
'client_id' => $this->api->getOptions()->getApiKey(),
201+
'client_secret' => $this->api->getOptions()->getApiSecret(),
202+
'grant_type' => 'refresh_token',
203+
'refresh_token' => $refreshToken,
204+
]);
205+
}
206+
207+
/**
208+
* POST /admin/oauth/access_token (JSON body).
209+
*
210+
* @param array $json
211+
*
212+
* @return ResponseAccess
213+
*/
214+
protected function oauthAccessTokenPost(array $json): ResponseAccess
215+
{
162216
$response = $this->api->request(
163217
'POST',
164218
'/admin/oauth/access_token',
165219
[
166-
'json' => $data,
220+
'json' => $json,
167221
]
168222
);
169223

170224
if (isset($response['errors']) && $response['errors'] === true) {
171225
throw new ApiException(
172226
is_string($response['body']) ? $response['body'] : 'Unknown error',
173227
0,
174-
$response['exception']
228+
$response['exception'] ?? null
175229
);
176230
}
177231

178232
return $response['body'];
179233
}
180234

181-
/**
182-
* {@inheritdoc}
183-
*
184-
* @codeCoverageIgnore No need to retest.
185-
*/
186-
public function getAccessData(string $code): ResponseAccess
187-
{
188-
return $this->api->requestAccess($code);
189-
}
190-
191235
/**
192236
* {@inheritdoc}
193237
* TODO: Convert to GraphQL.

0 commit comments

Comments
 (0)