diff --git a/README.md b/README.md index 5310a5ab..f6df3bef 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ You can follow our [getting started guide](docs/) to learn how to use this libra - [Make a GraphQL API call](docs/usage/graphql.md) - [Make a Storefront API call](docs/usage/storefront.md) - [Webhooks](docs/usage/webhooks.md) +- [Utilities](docs/usage/utils.md) - [Known issues and caveats](docs/issues.md) - [Notes on session handling](docs/issues.md#notes-on-session-handling) diff --git a/docs/README.md b/docs/README.md index c8833cdd..ec4b93a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,5 +14,6 @@ You can follow our getting started guide to learn how to use this library's comp - [Make a GraphQL API call](usage/graphql.md) - [Make a Storefront API call](usage/storefront.md) - [Webhooks](usage/webhooks.md) +- [Utilities](usage/utils.md) - [Known issues and caveats](issues.md) - [Notes on session handling](issues.md#notes-on-session-handling) diff --git a/docs/usage/oauth.md b/docs/usage/oauth.md index 20484cd6..595e944c 100644 --- a/docs/usage/oauth.md +++ b/docs/usage/oauth.md @@ -2,12 +2,14 @@ Once the library is set up for your project, you'll be able to use it to start adding functionality to your app. The first thing your app will need to do is to obtain an access token to the Admin API by performing the OAuth process. You can read our [OAuth tutorial](https://shopify.dev/tutorials/authenticate-with-oauth) to learn more about the process. +Once you've implemented these actions in your app, please make sure to read our [notes on session handling](../issues.md#notes-on-session-handling). + ## Begin OAuth -Create a route for starting the OAuth method such as `/login`. In this route, the `begin` method located in `src/Auth/OAuth.php` will be used. The method takes in a Shopify shop domain or hostname (_string_), the redirect path (_string_), and whether or not you are requesting [online access](https://shopify.dev/concepts/about-apis/authentication#api-access-modes) (_boolean_). The last parameter is optional and is an override function to set cookies. The `begin` method returns a URL that will be used for redirecting the user to the Shopify Authentication screen. +Create a route for starting the OAuth method such as `/login`. In this route, the `Shopify\Auth\OAuth::begin` method will be used. The method takes in a Shopify shop domain or hostname (_string_), the redirect path (_string_), and whether or not you are requesting [online access](https://shopify.dev/concepts/about-apis/authentication#api-access-modes) (_boolean_). The last parameter is optional and is an override function to set cookies. The `begin` method returns a URL that will be used for redirecting the user to the Shopify Authentication screen. | Parameter | Type | Required? | Default Value | Notes | -| -------------- | ----------------------------------- | :-------: | :-----------: | ---------------------------------------------------------------------------------------- | +| --- | --- | :---: | :---: | --- | | `shop` | `string` | Yes | - | A Shopify domain name or hostname that will be converted to the form `exampleshop.myshopify.com`. | | `redirectPath` | `string` | Yes | - | The redirect path used for callback with an optional leading `/` (e.g. both `auth/callback` and `/auth/callback` are acceptable). The route should be whitelisted under the app settings. | | `isOnline` | `bool` | Yes | - | `true` if the session is online and `false` otherwise. | @@ -31,11 +33,31 @@ function () use (Shopify\Auth\OAuthCookie $cookie) { ## OAuth callback -To complete the OAuth process, your app can call the `OAuth.callback` method, which takes in the following arguments: +To complete the OAuth process, your app needs to validate the callback request made by Shopify after the merchant authorizes your app to access their store data. + +To do that, you can call the `Shopify\Auth\OAuth::callback` method in the endpoint defined in the `redirectPath` argument of the [begin method](#begin-oauth), which takes in the following arguments: | Parameter | Type | Required? | Default Value | Notes | -| -------------- | ----------------------------------- | :-------: | :-----------: | ---------------------------------------------------------------------------------------- | -| `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` | +| --- | --- | :---: | :---: | --- | +| `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. | +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. + +## 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. + +The `Session` object provides the following methods to expose its data: +| Method | Return Type | Returned data | +| --- | --- | --- | +| `getId` | `string` | The id of the session. | +| `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. | +| `isOnline` | `bool` | Whether the session is [online or offline](https://shopify.dev/concepts/about-apis/authentication#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. | + [Back to guide index](../README.md) diff --git a/docs/usage/utils.md b/docs/usage/utils.md new file mode 100644 index 00000000..c142b709 --- /dev/null +++ b/docs/usage/utils.md @@ -0,0 +1,112 @@ +# Utility methods + +The library provides a set of functions that make it easier to perform certain tasks. These functions allow apps to: + +1. Execute smaller parts of the logic required for Shopify apps individually +1. Leverage the above functions to avoid repetition, by providing shortcuts to features that are often used together + +These methods are provided as a collection of static methods under `Shopify\Utils`. The following methods are currently supported: + +- [`sanitizeShopDomain`](#sanitizeShopDomain) +- [`getQueryParams`](#getQueryParams) +- [`validateHmac`](#validateHmac) +- [`decodeSessionToken`](#decodeSessionToken) +- [`isApiVersionCompatible`](#isApiVersionCompatible) +- [`loadOfflineSession`](#loadOfflineSession) +- [`loadCurrentSession`](#loadCurrentSession) +- [`graphqlProxy`](#graphqlProxy) + +## `sanitizeShopDomain` + +Returns a sanitized Shopify shop domain, ensuring that the domain is always in the format `my-domain.myshopify.com`. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `shop` | `string` | Yes | - | A Shopify shop domain or hostname | +| `myshopifyDomain` | `string \| null` | No | `'myshopify.com'` | A custom Shopify domain, mostly used for testing | + +This method will return a `string`, or `null` if the domain is invalid. + +## `getQueryParams` + +Retrieves the query string arguments from a URL string, if any. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `url` | `string` | Yes | - | The url string with query parameters to be extracted | + +This method will return an associative array containing the query parameters. + +## `validateHmac` + +Determines if a request is valid by checking the HMAC hash received in a request. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `params` | `array` | Yes | - | Query parameters from a URL | +| `secret` | `string` | Yes | - | The secret key associated with the app in the Partners Dashboard | + +This method will return whether the `hmac` key in `params` is valid. + +## `decodeSessionToken` + +Decodes the given session token (JWT) and extracts its payload, using `Context::$API_SECRET_KEY` as the secret. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `jwt` | `string` | Yes | - | The JWT to decode | + +This method will return the payload of the JWT. + +## `isApiVersionCompatible` + +Checks if the current version of the app (from `Context::$API_VERSION`) is compatible, i.e. more recent, than the given reference version. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `referenceVersion` | `string` | Yes | - | The version to check against | + +This method will return `true` if the current version in `Context` is more recent than (or equal to) the reference version. + +## `loadOfflineSession` + +Loads an offline session. This method **does not** perform any validation on the shop domain, so it **must not** rely on user input for the domain. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `shop` | `string` | Yes | - | The shop url to find the offline session for | +| `includeExpired` | `bool` | No | `false` | Include expired sessions | + +This method will return a `Session` object if a session exists, or `null` otherwise. Please refer to the [OAuth documentation](./oauth.md#the-session-object) for more information. + +## `loadCurrentSession` + +Loads the current user's session based on the given headers and cookies. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `rawHeaders` | `array` | Yes | - | The headers from the HTTP request | +| `cookies` | `array` | Yes | - | The cookies from the HTTP request | +| `isOnline` | `bool` | Yes | - | Whether to load online or offline sessions | + +This method will return a `Session` object if a session exists, or `null` otherwise. Please refer to the [OAuth documentation](./oauth.md#the-session-object) for more information. + +## `graphqlProxy` + +Forwards the GraphQL query in the HTTP request to Shopify, returning the response. + +Accepted arguments: +| Parameter | Type | Required | Default Value | Notes | +| --- | --- | :---: | :---: | --- | +| `rawHeaders` | `array` | Yes | - | The headers from the HTTP request | +| `cookies` | `array` | Yes | - | The cookies from the HTTP request | +| `rawBody` | `string` | Yes | - | The raw HTTP request payload | + +This method will return a `HttpResponse` object. Please refer to the [GraphQL client documentation](./graphql.md) for more information. diff --git a/src/Auth/OAuth.php b/src/Auth/OAuth.php index ab0f0fb9..3372bfca 100644 --- a/src/Auth/OAuth.php +++ b/src/Auth/OAuth.php @@ -8,11 +8,11 @@ use Shopify\Clients\HttpHeaders; use Shopify\Clients\HttpResponse; use Shopify\Context; +use Shopify\Exception\CookieNotFoundException; use Shopify\Exception\CookieSetException; use Shopify\Exception\HttpRequestException; use Shopify\Exception\InvalidOAuthException; use Shopify\Exception\MissingArgumentException; -use Shopify\Exception\OAuthCookieNotFoundException; use Shopify\Exception\OAuthSessionNotFoundException; use Shopify\Exception\SessionStorageException; use Shopify\Utils; @@ -222,7 +222,7 @@ public static function getOfflineSessionId(string $shop): string * * @return string The ID of the current session * @throws \Shopify\Exception\MissingArgumentException - * @throws \Shopify\Exception\OAuthCookieNotFoundException + * @throws \Shopify\Exception\CookieNotFoundException */ public static function getCurrentSessionId(array $rawHeaders, array $cookies, bool $isOnline): string { @@ -251,7 +251,7 @@ public static function getCurrentSessionId(array $rawHeaders, array $cookies, bo } } else { if (!$cookies) { - throw new OAuthCookieNotFoundException('Could not find the OAuth cookie to retrieve the session ID'); + throw new CookieNotFoundException('Could not find the current session id in the cookies'); } $currentSessionId = self::getCookieSessionId($cookies); } @@ -260,18 +260,18 @@ public static function getCurrentSessionId(array $rawHeaders, array $cookies, bo } /** - * Fetches the OAuth session ID from the given cookies. + * Fetches the current session ID from the given cookies. * * @param array $cookies The $cookies param from `callback` * * @return string The ID of the current session - * @throws \Shopify\Exception\OAuthCookieNotFoundException + * @throws \Shopify\Exception\CookieNotFoundException */ private static function getCookieSessionId(array $cookies): string { $sessionId = $cookies[self::SESSION_ID_COOKIE_NAME] ?? null; if (!$sessionId) { - throw new OAuthCookieNotFoundException("Could not find the OAuth cookie to complete the callback"); + throw new CookieNotFoundException("Could not find the current session id in the cookies"); } return (string)$sessionId; diff --git a/src/Exception/CookieNotFoundException.php b/src/Exception/CookieNotFoundException.php new file mode 100644 index 00000000..2fd1e01f --- /dev/null +++ b/src/Exception/CookieNotFoundException.php @@ -0,0 +1,11 @@ +getShop(), $session->getAccessToken()); + + // If the body is not JSON, we forward it as a string + $parsedBody = json_decode($rawBody, true) ?: $rawBody; + return $client->query(data: $parsedBody); + } } diff --git a/tests/Auth/OAuthTest.php b/tests/Auth/OAuthTest.php index b40c777b..f38a0902 100644 --- a/tests/Auth/OAuthTest.php +++ b/tests/Auth/OAuthTest.php @@ -13,7 +13,6 @@ use Shopify\Exception\HttpRequestException; use Shopify\Exception\InvalidOAuthException; use Shopify\Exception\MissingArgumentException; -use Shopify\Exception\OAuthCookieNotFoundException; use Shopify\Exception\OAuthSessionNotFoundException; use Shopify\Exception\PrivateAppException; use Shopify\Exception\SessionStorageException; @@ -115,10 +114,8 @@ public function testCallbackFailsWithoutCookie() { $this->createTestSession(false); - $this->expectException(OAuthCookieNotFoundException::class); - $this->expectExceptionMessage( - 'Could not find the OAuth cookie to complete the callback' - ); + $this->expectException(\Shopify\Exception\CookieNotFoundException::class); + $this->expectExceptionMessage('Could not find the current session id in the cookies'); OAuth::callback([], []); } @@ -460,8 +457,8 @@ function () { public function testGetCurrentSessionIdRaisesCookieNotFoundException() { Context::$IS_EMBEDDED_APP = false; - $this->expectException(OAuthCookieNotFoundException::class); - $this->expectExceptionMessage('Could not find the OAuth cookie to retrieve the session ID'); + $this->expectException(\Shopify\Exception\CookieNotFoundException::class); + $this->expectExceptionMessage('Could not find the current session id in the cookies'); OAuth::getCurrentSessionId([], [], true); } diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php index 2e988782..db9aec23 100644 --- a/tests/BaseTestCase.php +++ b/tests/BaseTestCase.php @@ -38,6 +38,9 @@ public function setUp(): void ); Context::$RETRY_TIME_IN_SECONDS = 0; $this->version = require dirname(__FILE__) . '/../src/version.php'; + + // Make sure we always mock the transport layer so we don't accidentally make real requests + $this->mockTransportRequests([]); } /** diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 9f8d0b00..0901dfaf 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -6,9 +6,12 @@ use DateTime; use Firebase\JWT\JWT; -use Shopify\Auth\Session; use Shopify\Context; use Shopify\Utils; +use Shopify\Auth\OAuth; +use Shopify\Auth\Session; +use Shopify\Exception\SessionNotFoundException; +use ShopifyTest\Clients\MockRequest; final class UtilsTest extends BaseTestCase { @@ -189,6 +192,140 @@ public function testDecodeSessionToken() $this->assertEquals($payload, $actualPayload); } + public function testGraphqlProxyFailsWithNoSession() + { + $token = $this->encodeJwtPayload(); + $headers = ['Authorization' => "Bearer $token"]; + + $this->expectException(SessionNotFoundException::class); + Utils::graphqlProxy($headers, [], $this->testGraphqlQuery); + } + + public function testGraphqlProxyFailsWithJWTForNonEmbeddedApps() + { + $sessionId = 'exampleshop.myshopify.com_42'; + $session = new Session( + id: $sessionId, + shop: 'test-shop.myshopify.io', + isOnline: true, + state: '1234', + ); + $session->setAccessToken('token'); + + $this->assertTrue(Context::$SESSION_STORAGE->storeSession($session)); + + $token = $this->encodeJwtPayload(); + $headers = ['Authorization' => "Bearer $token"]; + $cookies = [OAuth::SESSION_ID_COOKIE_NAME => 'cookie_id']; + + // The session is valid and can be loaded from the headers + Context::$IS_EMBEDDED_APP = true; + $this->assertEquals($session, Utils::loadCurrentSession($headers, [], isOnline: true)); + + Context::$IS_EMBEDDED_APP = false; + $this->expectException(SessionNotFoundException::class); + Utils::graphqlProxy([], $cookies, $this->testGraphqlQuery); + } + + public function testGraphqlProxyFailsWithCookiesForEmbeddedApps() + { + $sessionId = 'cookie_id'; + $session = new Session( + id: $sessionId, + shop: 'test-shop.myshopify.io', + isOnline: true, + state: '1234', + ); + $session->setAccessToken('token'); + + $this->assertTrue(Context::$SESSION_STORAGE->storeSession($session)); + + $token = $this->encodeJwtPayload(); + $headers = ['Authorization' => "Bearer $token"]; + $cookies = [OAuth::SESSION_ID_COOKIE_NAME => 'cookie_id']; + + // The session is valid and can be loaded from the cookies + Context::$IS_EMBEDDED_APP = false; + $this->assertEquals($session, Utils::loadCurrentSession([], $cookies, isOnline: true)); + + Context::$IS_EMBEDDED_APP = true; + $this->expectException(SessionNotFoundException::class); + Utils::graphqlProxy($headers, [], $this->testGraphqlQuery); + } + + public function testGraphqlProxyFetchesDataWithJWT() + { + Context::$IS_EMBEDDED_APP = true; + + $sessionId = 'exampleshop.myshopify.com_42'; + $session = new Session( + id: $sessionId, + shop: 'test-shop.myshopify.io', + isOnline: true, + state: '1234', + ); + $session->setAccessToken('token'); + + $this->assertTrue(Context::$SESSION_STORAGE->storeSession($session)); + $this->assertEquals($session, Context::$SESSION_STORAGE->loadSession('exampleshop.myshopify.com_42')); + + $this->mockTransportRequests([ + new MockRequest( + response: $this->buildMockHttpResponse(200, $this->testGraphqlResponse), + url: "https://$this->domain/admin/api/" . Context::$API_VERSION . '/graphql.json', + method: 'POST', + headers: [ + 'Content-Type: application/graphql', + 'Content-Length: ' . strlen($this->testGraphqlQuery), + 'X-Shopify-Access-Token: token', + ], + body: $this->testGraphqlQuery, + ) + ]); + + $token = $this->encodeJwtPayload(); + $headers = ['Authorization' => "Bearer $token"]; + $response = Utils::graphqlProxy($headers, [], $this->testGraphqlQuery); + + $this->assertThat($response, new HttpResponseMatcher(decodedBody: $this->testGraphqlResponse)); + } + + public function testGraphqlProxyFetchesDataWithCookies() + { + Context::$IS_EMBEDDED_APP = false; + + $sessionId = 'exampleshop.myshopify.com_42'; + $session = new Session( + id: $sessionId, + shop: 'test-shop.myshopify.io', + isOnline: true, + state: '1234', + ); + $session->setAccessToken('token'); + + $this->assertTrue(Context::$SESSION_STORAGE->storeSession($session)); + $this->assertEquals($session, Context::$SESSION_STORAGE->loadSession('exampleshop.myshopify.com_42')); + + $this->mockTransportRequests([ + new MockRequest( + response: $this->buildMockHttpResponse(200, $this->testGraphqlResponse), + url: "https://$this->domain/admin/api/" . Context::$API_VERSION . '/graphql.json', + method: 'POST', + headers: [ + 'Content-Type: application/graphql', + 'Content-Length: ' . strlen($this->testGraphqlQuery), + 'X-Shopify-Access-Token: token', + ], + body: $this->testGraphqlQuery, + ) + ]); + + $cookies = [OAuth::SESSION_ID_COOKIE_NAME => $sessionId]; + $response = Utils::graphqlProxy([], $cookies, $this->testGraphqlQuery); + + $this->assertThat($response, new HttpResponseMatcher(decodedBody: $this->testGraphqlResponse)); + } + private function encodeJwtPayload(): string { $payload = [ @@ -204,4 +341,20 @@ private function encodeJwtPayload(): string ]; return JWT::encode($payload, Context::$API_SECRET_KEY); } + + private string $testGraphqlQuery = << [ + "shop" => [ + "name" => "Shoppity Shop", + ], + ], + ]; }