Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ For more information, tutorials, etc., please view the project's [wiki](../../wi

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

### Expiring offline access tokens

[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:

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`.
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`).
3. Keep `APP_KEY` stable: refresh tokens are stored encrypted with Laravel’s encrypter.

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.

If your `User` model overrides `$casts`, merge `datetime` casts for the two `*_expires_at` columns (the `ShopModel` trait uses `mergeCasts` when `initializeShopModel` runs).

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.

## Issue or request?

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.
Expand Down
10 changes: 9 additions & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@
</include>
<exclude>
<directory>src/Contracts/</directory>
<directory>src/Exceptions/</directory>
<file>src/Exceptions/ApiException.php</file>
<file>src/Exceptions/BaseException.php</file>
<file>src/Exceptions/ChargeNotRecurringException.php</file>
<file>src/Exceptions/ChargeNotRecurringOrOnetimeException.php</file>
<file>src/Exceptions/HttpException.php</file>
<file>src/Exceptions/InvalidShopDomainException.php</file>
<file>src/Exceptions/MissingAuthUrlException.php</file>
<file>src/Exceptions/MissingShopDomainException.php</file>
<file>src/Exceptions/SignatureVerificationException.php</file>
<directory>src/Objects/Enums/</directory>
<directory>src/resources/</directory>
<directory>src/Messaging/Events/</directory>
Expand Down
46 changes: 41 additions & 5 deletions src/Actions/InstallShop.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
namespace Osiset\ShopifyApp\Actions;

use Exception;
use Illuminate\Support\Carbon;
use Osiset\ShopifyApp\Contracts\Commands\Shop as IShopCommand;
use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery;
use Osiset\ShopifyApp\Contracts\ShopModel as IShopModel;
use Osiset\ShopifyApp\Objects\Enums\AuthMode;
use Osiset\ShopifyApp\Objects\Enums\ThemeSupportLevel as ThemeSupportLevelEnum;
use Osiset\ShopifyApp\Objects\Values\AccessToken;
Expand Down Expand Up @@ -32,9 +34,9 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
}

$apiHelper = $shop->apiHelper();
$grantMode = $shop->hasOfflineAccess() ?
AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) :
AuthMode::OFFLINE();
$grantMode = $shop->hasOfflineAccess()
? AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop))
: AuthMode::OFFLINE();

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

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

try {
$themeSupportLevel = call_user_func($this->verifyThemeSupport, $shop->getId());
Expand All @@ -76,4 +80,36 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
];
}
}

/**
* Persist OAuth tokens and optional expiring-offline metadata.
*
* @param IShopModel $shop
* @param mixed $data
* @param AuthMode $grantMode
*
* @return void
*/
protected function persistShopifyOAuthTokens(IShopModel $shop, $data, AuthMode $grantMode): void
{
$expiringEnabled = Util::getShopifyConfig('expiring_offline_tokens', $shop);
$isOfflineGrant = $grantMode->isSame(AuthMode::OFFLINE());

if ($expiringEnabled && $isOfflineGrant && isset($data['refresh_token'])) {
$this->shopCommand->setAccessToken(
$shop->getId(),
AccessToken::fromNative($data['access_token']),
$data['refresh_token'],
Carbon::now()->addSeconds((int) $data['expires_in']),
Carbon::now()->addSeconds((int) $data['refresh_token_expires_in'])
);

return;
}

$this->shopCommand->setAccessToken(
$shop->getId(),
AccessToken::fromNative($data['access_token'])
);
}
}
16 changes: 14 additions & 2 deletions src/Contracts/ApiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,23 @@ public function performOfflineTokenExchange(string $token): ResponseAccess;
/**
* Finish the process by getting the access details from the code.
*
* @param string $code The code from the request.
* @param string $code The code from the request.
* @param AuthMode|null $grantMode Offline vs per-user grant (defaults to offline).
*
* @return ResponseAccess
*/
public function getAccessData(string $code): ResponseAccess;
public function getAccessData(string $code, ?AuthMode $grantMode = null): ResponseAccess;

/**
* Refresh an expiring offline access token using a refresh token.
*
* @link https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
*
* @param string $refreshToken The current offline refresh token.
*
* @return ResponseAccess
*/
public function refreshOfflineAccessToken(string $refreshToken): ResponseAccess;

/**
* Get the script tags for the shop.
Expand Down
18 changes: 15 additions & 3 deletions src/Contracts/Commands/Shop.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,24 @@ public function setToPlan(ShopIdValue $shopId, PlanIdValue $planId): bool;
/**
* Sets the access token (offline) from Shopify to the shop.
*
* @param ShopIdValue $shopId The shop's ID.
* @param AccessTokenValue $token The token from Shopify Oauth.
* When expiring offline tokens are used, pass the refresh token and expiry
* timestamps; otherwise omit them to clear expiring-offline metadata.
*
* @param ShopIdValue $shopId The shop's ID.
* @param AccessTokenValue $token The token from Shopify OAuth.
* @param string|null $offlineRefreshTokenPlain Decrypted refresh token (stored encrypted).
* @param \DateTimeInterface|null $offlineAccessTokenExpiresAt Access token expiry.
* @param \DateTimeInterface|null $offlineRefreshTokenExpiresAt Refresh token expiry.
*
* @return bool
*/
public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bool;
public function setAccessToken(
ShopIdValue $shopId,
AccessTokenValue $token,
?string $offlineRefreshTokenPlain = null,
$offlineAccessTokenExpiresAt = null,
$offlineRefreshTokenExpiresAt = null
): bool;

/**
* Sets the Online Store 2.0 support level
Expand Down
7 changes: 7 additions & 0 deletions src/Contracts/ShopModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ public function isFreemium(): bool;
*/
public function hasOfflineAccess(): bool;

/**
* Whether the shop has expiring offline token metadata (encrypted refresh token stored).
*
* @return bool
*/
public function hasExpiringOfflineAccess(): bool;

/**
* Get the API helper instance for a shop.
* TODO: Find a better way than using resolve(). However, we can't inject in model constructors.
Expand Down
12 changes: 12 additions & 0 deletions src/Exceptions/OAuthTokenRefreshException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Osiset\ShopifyApp\Exceptions;

use Exception;

/**
* Thrown when refreshing an expiring offline access token fails (e.g. invalid or expired refresh token).
*/
class OAuthTokenRefreshException extends Exception
{
}
68 changes: 56 additions & 12 deletions src/Services/ApiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ public function verifyRequest(array $request): bool
*/
public function performOfflineTokenExchange(string $token): ResponseAccess
{
$shop = $this->getShopDomain($this->api->getSession())->toNative();
$data = [
'client_id' => $this->api->getOptions()->getApiKey(),
'client_secret' => $this->api->getOptions()->getApiSecret(),
Expand All @@ -159,35 +160,78 @@ public function performOfflineTokenExchange(string $token): ResponseAccess
'subject_token_type' => 'urn:ietf:params:oauth:token-type:id_token',
'requested_token_type' => 'urn:shopify:params:oauth:token-type:offline-access-token',
];
if (Util::getShopifyConfig('expiring_offline_tokens', $shop)) {
$data['expiring'] = 1;
}

return $this->oauthAccessTokenPost($data);
}

/**
* {@inheritdoc}
*
* @codeCoverageIgnore No need to retest.
*/
public function getAccessData(string $code, ?AuthMode $grantMode = null): ResponseAccess
{
$grantMode = $grantMode ?? AuthMode::OFFLINE();
$shop = $this->getShopDomain($this->api->getSession())->toNative();
$useExpiringOffline = Util::getShopifyConfig('expiring_offline_tokens', $shop)
&& $grantMode->isSame(AuthMode::OFFLINE());

if ($useExpiringOffline) {
return $this->oauthAccessTokenPost([
'client_id' => $this->api->getOptions()->getApiKey(),
'client_secret' => $this->api->getOptions()->getApiSecret(),
'code' => $code,
'expiring' => 1,
]);
}

return $this->api->requestAccess($code);
}

/**
* {@inheritdoc}
*/
public function refreshOfflineAccessToken(string $refreshToken): ResponseAccess
{
return $this->oauthAccessTokenPost([
'client_id' => $this->api->getOptions()->getApiKey(),
'client_secret' => $this->api->getOptions()->getApiSecret(),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
}

/**
* POST /admin/oauth/access_token (JSON body).
*
* @param array $json
*
* @return ResponseAccess
*/
protected function oauthAccessTokenPost(array $json): ResponseAccess
{
$response = $this->api->request(
'POST',
'/admin/oauth/access_token',
[
'json' => $data,
'json' => $json,
]
);

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

return $response['body'];
}

/**
* {@inheritdoc}
*
* @codeCoverageIgnore No need to retest.
*/
public function getAccessData(string $code): ResponseAccess
{
return $this->api->requestAccess($code);
}

/**
* {@inheritdoc}
* TODO: Convert to GraphQL.
Expand Down
Loading