From b44b9065937c0b0c21b3791db9e93ae22faf0001 Mon Sep 17 00:00:00 2001 From: tanseercena Date: Tue, 28 Jul 2026 11:35:12 +0500 Subject: [PATCH] Add support for token exchange grant and expiring offline access tokens - Add OAuth::tokenExchange() to obtain a session directly from an App Bridge session token via the OAuth 2.0 token exchange grant, for both online and offline (expiring or non-expiring) access tokens. - Add OAuth::refreshAccessToken() to refresh an expiring offline access token using its refresh token, per the refresh_token grant. - Add an $expiringOfflineAccessToken parameter to OAuth::callback() so apps using the authorization code grant can also request expiring offline tokens. - Add Session::getRefreshToken()/setRefreshToken() and getRefreshTokenExpiresAt()/setRefreshTokenExpiresAt(). - Add RequestedTokenType with the ONLINE_ACCESS_TOKEN and OFFLINE_ACCESS_TOKEN constants used by the token exchange grant. - Extend AccessTokenResponse with optional expiresIn/refreshToken/ refreshTokenExpiresIn fields, populated for expiring offline tokens. Closes #462 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + docs/usage/oauth.md | 39 ++++- src/Auth/AccessTokenResponse.php | 45 +++++- src/Auth/OAuth.php | 213 ++++++++++++++++++++++-- src/Auth/RequestedTokenType.php | 16 ++ src/Auth/Session.php | 52 ++++++ tests/Auth/AccessTokenResponseTest.php | 31 ++++ tests/Auth/OAuthTest.php | 216 +++++++++++++++++++++++++ tests/Auth/SessionTest.php | 8 + 9 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 src/Auth/RequestedTokenType.php create mode 100644 tests/Auth/AccessTokenResponseTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 479b7c9e..c1adc50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Unreleased +- [Minor] Add support for the OAuth token exchange grant (`OAuth::tokenExchange`) and expiring offline access tokens with refresh support (`OAuth::refreshAccessToken`), `Session::getRefreshToken`/`getRefreshTokenExpiresAt`, and an `expiringOfflineAccessToken` option on `OAuth::callback`. See [#462](https://github.com/Shopify/shopify-api-php/issues/462). ## v6.1.1 - 2026-03-02 - [#456](https://github.com/Shopify/shopify-api-php/pull/456) [Patch] Update firebase/php-jwt to ^7.0 to address security vulnerability (GHSA-2x45-7fc3-mxwq) diff --git a/docs/usage/oauth.md b/docs/usage/oauth.md index e50fc99a..3c4c1e24 100644 --- a/docs/usage/oauth.md +++ b/docs/usage/oauth.md @@ -42,9 +42,44 @@ To do that, you can call the `Shopify\Auth\OAuth::callback` method in the endpoi | `cookies` | `array` | Yes | - | HTTP request cookies, from which the OAuth session will be loaded. This must be a hash of `cookie name => value` pairs. The value will be cast to string so they may be objects that implement `toString`. | | `query` | `array` | Yes | - | The HTTP request URL query values. | | `setCookieFunction` | `callable` | No | - | An override function to set cookies in the HTTP request. In order to be framework-agnostic, the built-in `setcookie` method is applied. If that method does not work for your chosen framework, a function that sets cookies can be passed in. | +| `expiringOfflineAccessToken` | `bool` | No | `false` | Whether to request an [expiring offline access token](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens) (with a refresh token), instead of a non-expiring one. Has no effect for online sessions. | If successful, this method will return a `Session` object, which is described [below](#the-session-object). Once the session is created, you can use [utility methods](./utils.md) to fetch it. +## Token exchange + +If your app is [embedded](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant) and rendered in the Shopify Admin, you can use the [token exchange grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange) to obtain an access token directly from the session token that App Bridge provides, without redirecting the merchant through the authorization code grant flow described above. + +Call `Shopify\Auth\OAuth::tokenExchange`, which takes in the following arguments: + +| Parameter | Type | Required? | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `shop` | `string` | Yes | - | A Shopify domain name or hostname. | +| `sessionToken` | `string` | Yes | - | The session token (Shopify id token) provided by App Bridge, taken from the `Authorization` header or the `id_token` URL param. | +| `requestedTokenType` | `string` | Yes | - | The type of token to request, one of the `Shopify\Auth\RequestedTokenType::ONLINE_ACCESS_TOKEN` or `Shopify\Auth\RequestedTokenType::OFFLINE_ACCESS_TOKEN` constants. | +| `expiring` | `bool` | No | `false` | Whether the requested offline access token should be an expiring token with a refresh token. Has no effect when requesting an online access token. | + +```php +$session = Shopify\Auth\OAuth::tokenExchange( + $shop, + $sessionToken, + Shopify\Auth\RequestedTokenType::OFFLINE_ACCESS_TOKEN, + expiring: true, +); +``` + +This method stores and returns the resulting `Session`, just like `OAuth::callback` does. + +## Refreshing expiring offline access tokens + +Expiring offline access tokens come with a refresh token that can be used to obtain a new access token without merchant interaction. When an offline session has a refresh token (see the `Session` fields [below](#the-session-object)), call `Shopify\Auth\OAuth::refreshAccessToken` with that session to get a new one: + +```php +$newSession = Shopify\Auth\OAuth::refreshAccessToken($session); +``` + +Shopify rotates the refresh token every time it's used, so make sure to persist the `Session` returned by this method; the previous refresh token becomes unusable immediately. If the refresh token has expired (after 90 days of inactivity), or the session provided is an online session, this method throws an `InvalidArgumentException` or `HttpRequestException`, and you'll need to re-authenticate the merchant. + ## The `Session` object The OAuth process will create a new `Session` object and store it in your `Context::$SESSION_STORAGE`. This object is a collection of data that is needed to authenticate requests to Shopify, so you can access shop data using the Admin API. @@ -56,9 +91,11 @@ The `Session` object provides the following methods to expose its data: | `getShop` | `string` | The shop to which the session belongs. | | `getState` | `string` | The `state` of the session. This is mainly used for OAuth. | | `getScope` | `string \| null` | The effective API scopes enabled for this session. | -| `getExpires` | `DateTime \| null` | The expiration date of the session, or null if it is offline. | +| `getExpires` | `DateTime \| null` | The expiration date of the session, or null if it doesn't expire. | | `isOnline` | `bool` | Whether the session is [online or offline](https://shopify.dev/docs/apps/auth#api-access-modes). | | `getAccessToken` | `string \| null` | The Admin API access token for the session. | | `getOnlineAccessInfo` | `AccessTokenOnlineUserInfo \| null` | The data for the user associated with this session. Only applies to online sessions. | +| `getRefreshToken` | `string \| null` | The refresh token for this session, if it's an expiring offline access token. | +| `getRefreshTokenExpiresAt` | `DateTime \| null` | The expiration date of the refresh token, if this session has one. | [Back to guide index](../README.md) diff --git a/src/Auth/AccessTokenResponse.php b/src/Auth/AccessTokenResponse.php index 1ff9b3e8..dec629b3 100644 --- a/src/Auth/AccessTokenResponse.php +++ b/src/Auth/AccessTokenResponse.php @@ -9,12 +9,24 @@ class AccessTokenResponse protected string $accessToken; protected string $scope; + // Note: this is intentionally not named `expiresIn` to avoid clashing with the property of the same name + // declared on AccessTokenOnlineResponse, which has a different (non-nullable) type. + private readonly ?int $tokenExpiresIn; + private readonly ?string $refreshToken; + private readonly ?int $refreshTokenExpiresIn; + public function __construct( string $accessToken, - string $scope + string $scope, + ?int $expiresIn = null, + ?string $refreshToken = null, + ?int $refreshTokenExpiresIn = null ) { $this->accessToken = $accessToken; $this->scope = $scope; + $this->tokenExpiresIn = $expiresIn; + $this->refreshToken = $refreshToken; + $this->refreshTokenExpiresIn = $refreshTokenExpiresIn; } public function getAccessToken(): string @@ -26,4 +38,35 @@ public function getScope(): string { return $this->scope; } + + /** + * The number of seconds until the access token expires. Only present for expiring offline access tokens. + * + * @return int|null + */ + public function getExpiresIn(): ?int + { + return $this->tokenExpiresIn; + } + + /** + * The refresh token that can be used to obtain a new access token. Only present for expiring offline access + * tokens. + * + * @return string|null + */ + public function getRefreshToken(): ?string + { + return $this->refreshToken; + } + + /** + * The number of seconds until the refresh token expires. Only present for expiring offline access tokens. + * + * @return int|null + */ + public function getRefreshTokenExpiresIn(): ?int + { + return $this->refreshTokenExpiresIn; + } } diff --git a/src/Auth/OAuth.php b/src/Auth/OAuth.php index e7011032..a702c5f4 100644 --- a/src/Auth/OAuth.php +++ b/src/Auth/OAuth.php @@ -33,6 +33,9 @@ class OAuth public const SESSION_ID_COOKIE_NAME = 'shopify_session_id'; public const SESSION_ID_SIG_COOKIE_NAME = 'shopify_session_id_sig'; public const ACCESS_TOKEN_POST_PATH = '/admin/oauth/access_token'; + public const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; + public const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token'; + public const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'; /** * Begins the OAuth process by setting the appropriate cookies, and returns the authorization url @@ -96,11 +99,15 @@ public static function begin( * Performs the OAuth callback steps, checking the returned parameters and fetching the access token, preparing the * session for further usage. If successful, the updated session is returned. * - * @param array $cookies HTTP request cookies, from which the OAuth session will be loaded. This - * must be a hash of cookie name => value pairs. Value will be forcibly cast - * to string so objects that implement toString will also work. - * @param array $query The HTTP request URL query values. - * @param null|callable $setCookieFunction An optional override for setting cookie in response. + * @param array $cookies HTTP request cookies, from which the OAuth session will be + * loaded. This must be a hash of cookie name => value pairs. + * Value will be forcibly cast to string so objects that + * implement toString will also work. + * @param array $query The HTTP request URL query values. + * @param null|callable $setCookieFunction An optional override for setting cookie in response. + * @param bool $expiringOfflineAccessToken Whether to request an expiring offline access token (with a + * refresh token) instead of a non-expiring one. Has no effect + * for online sessions. * * @return Session * @throws HttpRequestException @@ -111,8 +118,12 @@ public static function begin( * @throws SessionStorageException * @throws UninitializedContextException */ - public static function callback(array $cookies, array $query, ?callable $setCookieFunction = null): Session - { + public static function callback( + array $cookies, + array $query, + ?callable $setCookieFunction = null, + bool $expiringOfflineAccessToken = false + ): Session { Context::throwIfUninitialized(); Context::throwIfPrivateApp('OAuth is not allowed for private apps'); @@ -122,7 +133,7 @@ public static function callback(array $cookies, array $query, ?callable $setCook } $sanitizedShop = Utils::sanitizeShopDomain($query['shop'] ?? ''); - $response = self::fetchAccessToken($query, $sanitizedShop); + $response = self::fetchAccessToken($query, $sanitizedShop, $expiringOfflineAccessToken); $isOnline = $response instanceof AccessTokenOnlineResponse; @@ -143,6 +154,10 @@ public static function callback(array $cookies, array $query, ?callable $setCook $jwtSessionId = self::getJwtSessionId($session->getShop(), $session->getOnlineAccessInfo()->getId()); $session = $session->clone($jwtSessionId); } + } else { + // Offline sessions only carry expiry/refresh token data when the authorization code request included + // `expiring=1`. For non-expiring offline tokens, these fields are simply absent from the response. + self::applyOfflineTokenLifecycle($session, $response); } $sessionStored = Context::$SESSION_STORAGE->storeSession($session); @@ -167,6 +182,149 @@ public static function callback(array $cookies, array $query, ?callable $setCook return $session; } + /** + * Exchanges a session token (the Shopify id token provided by App Bridge) for an access token, using the + * OAuth 2.0 token exchange grant. This is the recommended way for embedded apps to authenticate, since it + * doesn't require redirecting the merchant through the authorization code grant flow. + * + * @param string $shop A Shopify domain name or hostname + * @param string $sessionToken The session token (id token) provided by App Bridge, either from the + * `Authorization` header or the `id_token` URL param + * @param string $requestedTokenType The type of token being requested, one of the RequestedTokenType constants + * @param bool $expiring Whether the requested offline access token should be expiring (with a + * refresh token). Has no effect when requesting an online access token. + * + * @return Session + * @throws HttpRequestException + * @throws PrivateAppException + * @throws SessionStorageException + * @throws UninitializedContextException + */ + public static function tokenExchange( + string $shop, + string $sessionToken, + string $requestedTokenType, + bool $expiring = false + ): Session { + Context::throwIfUninitialized(); + Context::throwIfPrivateApp('OAuth is not allowed for private apps'); + + $sanitizedShop = Utils::sanitizeShopDomain($shop); + if (!isset($sanitizedShop)) { + throw new InvalidArgumentException("Invalid shop domain: $shop"); + } + + $post = [ + 'client_id' => Context::$API_KEY, + 'client_secret' => Context::$API_SECRET_KEY, + 'grant_type' => self::TOKEN_EXCHANGE_GRANT_TYPE, + 'subject_token' => $sessionToken, + 'subject_token_type' => self::ID_TOKEN_TYPE, + 'requested_token_type' => $requestedTokenType, + ]; + + if ($requestedTokenType === RequestedTokenType::OFFLINE_ACCESS_TOKEN) { + $post['expiring'] = $expiring ? '1' : '0'; + } + + $client = new Http($sanitizedShop); + $response = self::requestAccessToken($client, $post); + if ($response->getStatusCode() !== 200) { + throw new HttpRequestException("Failed to exchange token: {$response->getDecodedBody()}"); + } + + $body = $response->getDecodedBody(); + if (array_key_exists('associated_user', $body) && $body['associated_user']) { + $tokenResponse = self::buildAccessTokenOnlineResponse($body); + } else { + $tokenResponse = self::buildAccessTokenResponse($body); + } + + $isOnline = $tokenResponse instanceof AccessTokenOnlineResponse; + $sessionId = $isOnline + ? self::getJwtSessionId($sanitizedShop, $tokenResponse->getAssociatedUser()->getId()) + : self::getOfflineSessionId($sanitizedShop); + + $session = new Session($sessionId, $sanitizedShop, $isOnline, ''); + $session->setAccessToken($tokenResponse->getAccessToken()); + $session->setScope($tokenResponse->getScope()); + + if ($isOnline) { + /** @var AccessTokenOnlineResponse $tokenResponse */ + $session->setExpires(time() + $tokenResponse->getExpiresIn()); + $session->setOnlineAccessInfo($tokenResponse->getAssociatedUser()); + } else { + self::applyOfflineTokenLifecycle($session, $tokenResponse); + } + + $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; + } + + /** + * Uses a session's refresh token to obtain a new access token for an expiring offline access token, without + * requiring merchant interaction. Shopify rotates the refresh token on every use, so the returned session's + * refresh token must be persisted; the previous refresh token becomes unusable immediately. + * + * @param Session $session An offline session with a refresh token, as set by `tokenExchange` or `callback` + * + * @return Session The updated session, with a new access token, refresh token, and expiry times + * @throws HttpRequestException + * @throws InvalidArgumentException + * @throws PrivateAppException + * @throws SessionStorageException + * @throws UninitializedContextException + */ + public static function refreshAccessToken(Session $session): Session + { + Context::throwIfUninitialized(); + Context::throwIfPrivateApp('OAuth is not allowed for private apps'); + + if ($session->isOnline()) { + throw new InvalidArgumentException('The refresh token grant is only supported for offline sessions'); + } + + $refreshToken = $session->getRefreshToken(); + if (!$refreshToken) { + throw new InvalidArgumentException('Session does not have a refresh token to use for this grant'); + } + + $post = [ + 'client_id' => Context::$API_KEY, + 'client_secret' => Context::$API_SECRET_KEY, + 'grant_type' => self::REFRESH_TOKEN_GRANT_TYPE, + 'refresh_token' => $refreshToken, + ]; + + $client = new Http($session->getShop()); + $response = self::requestAccessToken($client, $post); + if ($response->getStatusCode() !== 200) { + throw new HttpRequestException("Failed to refresh access token: {$response->getDecodedBody()}"); + } + + $tokenResponse = self::buildAccessTokenResponse($response->getDecodedBody()); + + $newSession = $session->clone($session->getId()); + $newSession->setAccessToken($tokenResponse->getAccessToken()); + $newSession->setScope($tokenResponse->getScope()); + self::applyOfflineTokenLifecycle($newSession, $tokenResponse); + + $sessionStored = Context::$SESSION_STORAGE->storeSession($newSession); + if (!$sessionStored) { + throw new SessionStorageException( + 'OAuth Session could not be saved. Please check your session storage functionality.', + ); + } + + return $newSession; + } + /** * Builds a session id that can be loaded from JWTs from App Bridge * @@ -399,13 +557,14 @@ private static function isCallbackQueryValid(array $query, string | null $stateC /** * Fetches the access token for the given OAuth session, using the query parameters returned by Shopify * - * @param array $query The URL query params from the OAuth callback - * @param string $shop The request shop + * @param array $query The URL query params from the OAuth callback + * @param string $shop The request shop + * @param bool $expiring Whether to request an expiring offline access token (with a refresh token) * * @return AccessTokenResponse|AccessTokenOnlineResponse The access token exchanged for the OAuth code * @throws HttpRequestException */ - private static function fetchAccessToken(array $query, string $shop) + private static function fetchAccessToken(array $query, string $shop, bool $expiring = false) { $post = [ 'client_id' => Context::$API_KEY, @@ -413,6 +572,10 @@ private static function fetchAccessToken(array $query, string $shop) 'code' => $query['code'], ]; + if ($expiring) { + $post['expiring'] = '1'; + } + $client = new Http($shop); $response = self::requestAccessToken($client, $post); if ($response->getStatusCode() !== 200) { @@ -461,7 +624,33 @@ private static function buildAccessTokenOnlineResponse(array $body): AccessToken */ private static function buildAccessTokenResponse(array $body): AccessTokenResponse { - return new AccessTokenResponse($body['access_token'], $body['scope']); + return new AccessTokenResponse( + $body['access_token'], + $body['scope'], + $body['expires_in'] ?? null, + $body['refresh_token'] ?? null, + $body['refresh_token_expires_in'] ?? null, + ); + } + + /** + * Applies expiry and refresh token data from an offline access token response to a session, when present. + * Non-expiring offline tokens simply won't have this data in their response. + * + * @param Session $session The offline session to update + * @param AccessTokenResponse $response The token response returned by Shopify + */ + private static function applyOfflineTokenLifecycle(Session $session, AccessTokenResponse $response): void + { + if ($response->getExpiresIn() !== null) { + $session->setExpires(time() + $response->getExpiresIn()); + } + if ($response->getRefreshToken() !== null) { + $session->setRefreshToken($response->getRefreshToken()); + } + if ($response->getRefreshTokenExpiresIn() !== null) { + $session->setRefreshTokenExpiresAt(time() + $response->getRefreshTokenExpiresIn()); + } } /** diff --git a/src/Auth/RequestedTokenType.php b/src/Auth/RequestedTokenType.php new file mode 100644 index 00000000..8939c310 --- /dev/null +++ b/src/Auth/RequestedTokenType.php @@ -0,0 +1,16 @@ +onlineAccessInfo; } + /** + * The refresh token for this session, if it was obtained as an expiring offline access token. This is only + * populated for offline sessions obtained via the token exchange or refresh token grants with `expiring=1`. + * + * @return string|null + */ + public function getRefreshToken() + { + return $this->refreshToken; + } + + /** + * The date and time at which this session's refresh token expires, if it has one. + * + * @return DateTime|null + */ + public function getRefreshTokenExpiresAt() + { + return $this->refreshTokenExpiresAt; + } + public function setScope(string $scope): void { $this->scope = $scope; @@ -122,6 +147,31 @@ public function setOnlineAccessInfo(AccessTokenOnlineUserInfo $onlineAccessInfo) $this->onlineAccessInfo = $onlineAccessInfo; } + public function setRefreshToken(?string $refreshToken): void + { + $this->refreshToken = $refreshToken; + } + + /** + * @param string|int|DateTime|null $refreshTokenExpiresAt + * + * @throws Exception + */ + public function setRefreshTokenExpiresAt($refreshTokenExpiresAt): void + { + $date = null; + if ($refreshTokenExpiresAt) { + if (is_string($refreshTokenExpiresAt)) { + $date = new DateTime($refreshTokenExpiresAt); + } elseif (is_numeric($refreshTokenExpiresAt)) { + $date = new DateTime("@$refreshTokenExpiresAt"); + } else { + $date = $refreshTokenExpiresAt; + } + } + $this->refreshTokenExpiresAt = $date; + } + /** * Creates a clone of the current session with a new id. * @@ -136,6 +186,8 @@ public function clone(string $newSessionId): Session $newSession->expires = $this->expires; $newSession->accessToken = $this->accessToken; $newSession->onlineAccessInfo = $this->onlineAccessInfo; + $newSession->refreshToken = $this->refreshToken; + $newSession->refreshTokenExpiresAt = $this->refreshTokenExpiresAt; return $newSession; } diff --git a/tests/Auth/AccessTokenResponseTest.php b/tests/Auth/AccessTokenResponseTest.php new file mode 100644 index 00000000..1b6316dc --- /dev/null +++ b/tests/Auth/AccessTokenResponseTest.php @@ -0,0 +1,31 @@ +assertEquals('test_token', $response->getAccessToken()); + $this->assertEquals('test_scope', $response->getScope()); + $this->assertNull($response->getExpiresIn()); + $this->assertNull($response->getRefreshToken()); + $this->assertNull($response->getRefreshTokenExpiresIn()); + } + + public function testGettersForExpiringToken() + { + $response = new AccessTokenResponse('test_token', 'test_scope', 3600, 'test_refresh_token', 7776000); + $this->assertEquals('test_token', $response->getAccessToken()); + $this->assertEquals('test_scope', $response->getScope()); + $this->assertEquals(3600, $response->getExpiresIn()); + $this->assertEquals('test_refresh_token', $response->getRefreshToken()); + $this->assertEquals(7776000, $response->getRefreshTokenExpiresIn()); + } +} diff --git a/tests/Auth/OAuthTest.php b/tests/Auth/OAuthTest.php index 5f2ae739..3728a382 100644 --- a/tests/Auth/OAuthTest.php +++ b/tests/Auth/OAuthTest.php @@ -8,6 +8,7 @@ use Exception; use Firebase\JWT\JWT; use Shopify\Auth\OAuth; +use Shopify\Auth\RequestedTokenType; use Shopify\Auth\Session; use Shopify\Auth\AccessTokenOnlineUserInfo; use Shopify\Auth\OAuthCookie; @@ -60,6 +61,15 @@ final class OAuthTest extends BaseTestCase 'scope' => 'read_products', ]; + /** @var array */ + private $offlineExpiringResponse = [ + 'access_token' => 'some access token', + 'scope' => 'read_products', + 'expires_in' => 3600, + 'refresh_token' => 'some refresh token', + 'refresh_token_expires_in' => 7776000, + ]; + /** * @dataProvider validBeginProvider */ @@ -521,6 +531,212 @@ public function testGetCurrentSessionIdForEmbeddedAppMissingHeaders() $this->assertEquals('offline_exampleshop.myshopify.com', $currentSessionId); } + /** + * @dataProvider tokenExchangeProvider + */ + public function testTokenExchange(bool $isOnline, bool $expiring) + { + $storage = new MockSessionStorage(); + Context::$SESSION_STORAGE = $storage; + + $requestedTokenType = $isOnline + ? RequestedTokenType::ONLINE_ACCESS_TOKEN + : RequestedTokenType::OFFLINE_ACCESS_TOKEN; + + $expectedBody = [ + 'client_id' => 'ash', + 'client_secret' => self::TEST_API_SECRET, + 'grant_type' => OAuth::TOKEN_EXCHANGE_GRANT_TYPE, + 'subject_token' => 'a session token', + 'subject_token_type' => OAuth::ID_TOKEN_TYPE, + 'requested_token_type' => $requestedTokenType, + ]; + if (!$isOnline) { + $expectedBody['expiring'] = $expiring ? '1' : '0'; + } + + $response = $isOnline ? $this->onlineResponse : ( + $expiring ? $this->offlineExpiringResponse : $this->offlineResponse + ); + + $this->mockTransportRequests([ + new MockRequest( + $this->buildMockHttpResponse(200, $response), + "https://test-shop.myshopify.io/admin/oauth/access_token", + "POST", + "^Shopify Admin API Library for PHP v", + ['Content-Type: application/json'], + json_encode($expectedBody), + ), + ]); + + $session = OAuth::tokenExchange($this->domain, 'a session token', $requestedTokenType, $expiring); + + $this->assertEquals('some access token', $session->getAccessToken()); + $this->assertEquals('read_products', $session->getScope()); + $this->assertEquals($isOnline, $session->isOnline()); + $this->assertNotNull($storage->loadSession($session->getId())); + + if ($isOnline) { + $this->assertEquals(OAuth::getJwtSessionId($this->domain, '1'), $session->getId()); + $this->assertNotNull($session->getOnlineAccessInfo()); + } else { + $this->assertEquals(OAuth::getOfflineSessionId($this->domain), $session->getId()); + if ($expiring) { + $this->assertEquals('some refresh token', $session->getRefreshToken()); + $this->assertNotNull($session->getExpires()); + $this->assertNotNull($session->getRefreshTokenExpiresAt()); + } else { + $this->assertNull($session->getRefreshToken()); + $this->assertNull($session->getExpires()); + } + } + } + + public function tokenExchangeProvider(): array + { + return [ + 'Online' => [true, false], + 'Offline, non-expiring' => [false, false], + 'Offline, expiring' => [false, true], + ]; + } + + public function testTokenExchangeFailsForPrivateApp() + { + Context::$IS_PRIVATE_APP = true; + + $this->expectException(PrivateAppException::class); + OAuth::tokenExchange($this->domain, 'a session token', RequestedTokenType::OFFLINE_ACCESS_TOKEN); + } + + public function testTokenExchangeFailsIfRequestFails() + { + $expectedBody = [ + 'client_id' => 'ash', + 'client_secret' => self::TEST_API_SECRET, + 'grant_type' => OAuth::TOKEN_EXCHANGE_GRANT_TYPE, + 'subject_token' => 'a session token', + 'subject_token_type' => OAuth::ID_TOKEN_TYPE, + 'requested_token_type' => RequestedTokenType::OFFLINE_ACCESS_TOKEN, + 'expiring' => '0', + ]; + + $this->mockTransportRequests([ + new MockRequest( + $this->buildMockHttpResponse(400, ''), + "https://test-shop.myshopify.io/admin/oauth/access_token", + "POST", + "^Shopify Admin API Library for PHP v", + ['Content-Type: application/json'], + json_encode($expectedBody), + ), + ]); + + $this->expectException(HttpRequestException::class); + OAuth::tokenExchange($this->domain, 'a session token', RequestedTokenType::OFFLINE_ACCESS_TOKEN); + } + + public function testRefreshAccessToken() + { + $storage = new MockSessionStorage(); + Context::$SESSION_STORAGE = $storage; + + $session = new Session(OAuth::getOfflineSessionId($this->domain), $this->domain, false, ''); + $session->setAccessToken('old access token'); + $session->setScope('read_products'); + $session->setRefreshToken('old refresh token'); + + $expectedBody = [ + 'client_id' => 'ash', + 'client_secret' => self::TEST_API_SECRET, + 'grant_type' => OAuth::REFRESH_TOKEN_GRANT_TYPE, + 'refresh_token' => 'old refresh token', + ]; + + $this->mockTransportRequests([ + new MockRequest( + $this->buildMockHttpResponse(200, $this->offlineExpiringResponse), + "https://test-shop.myshopify.io/admin/oauth/access_token", + "POST", + "^Shopify Admin API Library for PHP v", + ['Content-Type: application/json'], + json_encode($expectedBody), + ), + ]); + + $newSession = OAuth::refreshAccessToken($session); + + $this->assertEquals($session->getId(), $newSession->getId()); + $this->assertEquals('some access token', $newSession->getAccessToken()); + $this->assertEquals('some refresh token', $newSession->getRefreshToken()); + $this->assertNotNull($newSession->getExpires()); + $this->assertNotNull($newSession->getRefreshTokenExpiresAt()); + $this->assertNotNull($storage->loadSession($newSession->getId())); + } + + public function testRefreshAccessTokenFailsForOnlineSession() + { + $session = new Session('some-id', $this->domain, true, ''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The refresh token grant is only supported for offline sessions'); + OAuth::refreshAccessToken($session); + } + + public function testRefreshAccessTokenFailsWithoutRefreshToken() + { + $session = new Session(OAuth::getOfflineSessionId($this->domain), $this->domain, false, ''); + $session->setAccessToken('old access token'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Session does not have a refresh token to use for this grant'); + OAuth::refreshAccessToken($session); + } + + public function testCallbackWithExpiringOfflineAccessToken() + { + $storage = new MockSessionStorage(); + Context::$SESSION_STORAGE = $storage; + + /** @var OAuthCookie[] */ + $cookiesSet = []; + $cookieCallback = function (OAuthCookie $cookie) use (&$cookiesSet) { + $cookiesSet[$cookie->getName()] = $cookie; + return !empty($cookie->getValue()); + }; + + $expectedBody = array_merge($this->codeRequestBody, ['expiring' => '1']); + + $this->mockTransportRequests([ + new MockRequest( + $this->buildMockHttpResponse(200, $this->offlineExpiringResponse), + "https://test-shop.myshopify.io/admin/oauth/access_token", + "POST", + "^Shopify Admin API Library for PHP v", + ['Content-Type: application/json'], + json_encode($expectedBody), + ), + ]); + + $mockCookies = [ + OAuth::STATE_SIG_COOKIE_NAME => hash_hmac('sha256', $this->state, Context::$API_SECRET_KEY), + OAuth::STATE_COOKIE_NAME => $this->state, + ]; + $mockQuery = [ + 'shop' => $this->domain, + 'state' => '1234', + 'code' => 'real_code', + 'hmac' => 'b104858f49be2f9dda979fb07f107e0ab337e0e0f32682560dbe9f03c25b5129', + ]; + + $session = OAuth::callback($mockCookies, $mockQuery, $cookieCallback, true); + + $this->assertEquals('some refresh token', $session->getRefreshToken()); + $this->assertNotNull($session->getExpires()); + $this->assertNotNull($session->getRefreshTokenExpiresAt()); + } + /** * Creates a session with all the default expected values * diff --git a/tests/Auth/SessionTest.php b/tests/Auth/SessionTest.php index e2f92e9b..69de9d5c 100644 --- a/tests/Auth/SessionTest.php +++ b/tests/Auth/SessionTest.php @@ -31,6 +31,8 @@ public function testSessionGetterAndSetterFunctions() true, ); $session->setOnlineAccessInfo($onlineAccessInfo); + $session->setRefreshToken('some_refresh_token'); + $session->setRefreshTokenExpiresAt('January 26, 2021'); $this->assertEquals('12345', $session->getId()); $this->assertEquals('my-shop.myshopify.io', $session->getShop()); @@ -40,6 +42,8 @@ public function testSessionGetterAndSetterFunctions() $this->assertTrue($session->isOnline()); $this->assertEquals('24ssdf243u2ohfd21', $session->getAccessToken()); $this->assertEquals($onlineAccessInfo, $session->getOnlineAccessInfo()); + $this->assertEquals('some_refresh_token', $session->getRefreshToken()); + $this->assertEquals('2021-01-26', $session->getRefreshTokenExpiresAt()->format('Y-m-d')); } public function testSetExpiresValues() @@ -75,6 +79,8 @@ public function testClone() true, ); $session->setOnlineAccessInfo($onlineAccessInfo); + $session->setRefreshToken('some_refresh_token'); + $session->setRefreshTokenExpiresAt('January 26, 2021'); $newSession = $session->clone('54321'); $this->assertNotEquals($session->getId(), $newSession->getId()); @@ -85,6 +91,8 @@ public function testClone() $this->assertEquals($session->getExpires(), $newSession->getExpires()); $this->assertEquals($session->getAccessToken(), $newSession->getAccessToken()); $this->assertEquals($session->getOnlineAccessInfo(), $newSession->getOnlineAccessInfo()); + $this->assertEquals($session->getRefreshToken(), $newSession->getRefreshToken()); + $this->assertEquals($session->getRefreshTokenExpiresAt(), $newSession->getRefreshTokenExpiresAt()); } public function testIsValidReturnsTrue()