Skip to content

Commit 5d0d716

Browse files
authored
LYNX-319 401 and 403 HTTP response codes for GraphQL API
1 parent b4572ec commit 5d0d716

File tree

8 files changed

+352
-22
lines changed

8 files changed

+352
-22
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\CustomerGraphQl\Controller\HttpRequestValidator;
9+
10+
use Magento\Framework\App\HttpRequestInterface;
11+
use Magento\Framework\Exception\AuthorizationException;
12+
use Magento\Framework\GraphQl\Exception\GraphQlAuthenticationException;
13+
use Magento\GraphQl\Controller\HttpRequestValidatorInterface;
14+
use Magento\Integration\Api\Exception\UserTokenException;
15+
use Magento\Integration\Api\UserTokenReaderInterface;
16+
use Magento\Integration\Api\UserTokenValidatorInterface;
17+
18+
class AuthorizationRequestValidator implements HttpRequestValidatorInterface
19+
{
20+
private const AUTH = 'Authorization';
21+
private const BEARER = 'bearer';
22+
23+
/**
24+
* AuthorizationRequestValidator Constructor
25+
*
26+
* @param UserTokenReaderInterface $tokenReader
27+
* @param UserTokenValidatorInterface $tokenValidator
28+
*/
29+
public function __construct(
30+
private readonly UserTokenReaderInterface $tokenReader,
31+
private readonly UserTokenValidatorInterface $tokenValidator
32+
) {
33+
}
34+
35+
/**
36+
* Validate the authorization header bearer token if it is set
37+
*
38+
* @param HttpRequestInterface $request
39+
* @return void
40+
* @throws GraphQlAuthenticationException
41+
*/
42+
public function validate(HttpRequestInterface $request): void
43+
{
44+
$authorizationHeaderValue = $request->getHeader(self::AUTH);
45+
if (!$authorizationHeaderValue) {
46+
return;
47+
}
48+
49+
$headerPieces = explode(' ', $authorizationHeaderValue);
50+
if (count($headerPieces) !== 2 || strtolower($headerPieces[0]) !== self::BEARER) {
51+
return;
52+
}
53+
54+
try {
55+
$this->tokenValidator->validate($this->tokenReader->read($headerPieces[1]));
56+
} catch (UserTokenException | AuthorizationException $exception) {
57+
throw new GraphQlAuthenticationException(__($exception->getMessage()));
58+
}
59+
}
60+
}

app/code/Magento/CustomerGraphQl/etc/graphql/di.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,11 @@
214214
<plugin name="merge_order_after_customer_signup"
215215
type="Magento\CustomerGraphQl\Plugin\Model\MergeGuestOrder" />
216216
</type>
217+
<type name="Magento\GraphQl\Controller\HttpRequestProcessor">
218+
<arguments>
219+
<argument name="requestValidators" xsi:type="array">
220+
<item name="authorizationValidator" xsi:type="object">Magento\CustomerGraphQl\Controller\HttpRequestValidator\AuthorizationRequestValidator</item>
221+
</argument>
222+
</arguments>
223+
</type>
217224
</config>

app/code/Magento/GraphQl/Controller/GraphQl.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use Magento\Framework\App\ResponseInterface;
2020
use Magento\Framework\Controller\Result\JsonFactory;
2121
use Magento\Framework\GraphQl\Exception\ExceptionFormatter;
22+
use Magento\Framework\GraphQl\Exception\GraphQlAuthenticationException;
23+
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
2224
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
2325
use Magento\Framework\GraphQl\Query\Fields as QueryFields;
2426
use Magento\Framework\GraphQl\Query\QueryParser;
@@ -185,7 +187,7 @@ public function dispatch(RequestInterface $request): ResponseInterface
185187
$statusCode = 200;
186188
$jsonResult = $this->jsonFactory->create();
187189
$data = [];
188-
$result = null;
190+
$result = ['errors' => []];
189191
$schema = null;
190192
$query = '';
191193

@@ -211,6 +213,13 @@ public function dispatch(RequestInterface $request): ResponseInterface
211213
$data['variables'] ?? []
212214
);
213215
}
216+
$statusCode = $this->getHttpResponseCode($result);
217+
} catch (GraphQlAuthenticationException $error) {
218+
$result['errors'][] = $this->graphQlError->create($error);
219+
$statusCode = 401;
220+
} catch (GraphQlAuthorizationException $error) {
221+
$result['errors'][] = $this->graphQlError->create($error);
222+
$statusCode = 403;
214223
} catch (SyntaxError|GraphQlInputException $error) {
215224
$result = [
216225
'errors' => [FormattedError::createFromException($error)],
@@ -230,14 +239,35 @@ public function dispatch(RequestInterface $request): ResponseInterface
230239
$jsonResult->renderResult($this->httpResponse);
231240

232241
// log information about the query, unless it is an introspection query
233-
if (strpos($query, 'IntrospectionQuery') === false) {
242+
if (!str_contains($query, 'IntrospectionQuery')) {
234243
$queryInformation = $this->logDataHelper->getLogData($request, $data, $schema, $this->httpResponse);
235244
$this->loggerPool->execute($queryInformation);
236245
}
237246

238247
return $this->httpResponse;
239248
}
240249

250+
/**
251+
* Retrieve http response code based on the error categories
252+
*
253+
* @param array $result
254+
* @return int
255+
*/
256+
private function getHttpResponseCode(array $result): int
257+
{
258+
foreach ($result['errors'] ?? [] as $error) {
259+
if (isset($error['extensions']['category'])) {
260+
return match ($error['extensions']['category']) {
261+
GraphQlAuthenticationException::EXCEPTION_CATEGORY => 401,
262+
GraphQlAuthorizationException::EXCEPTION_CATEGORY => 403,
263+
default => 200,
264+
};
265+
}
266+
}
267+
268+
return 200;
269+
}
270+
241271
/**
242272
* Get data from request body or query string
243273
*
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\GraphQl\Customer;
9+
10+
use Exception;
11+
use Magento\Customer\Api\CustomerRepositoryInterface;
12+
use Magento\Customer\Api\Data\CustomerInterface;
13+
use Magento\Customer\Test\Fixture\Customer;
14+
use Magento\Framework\Exception\AuthenticationException;
15+
use Magento\Framework\Exception\EmailNotConfirmedException;
16+
use Magento\Framework\Exception\LocalizedException;
17+
use Magento\Framework\Exception\NoSuchEntityException;
18+
use Magento\Integration\Api\CustomerTokenServiceInterface;
19+
use Magento\TestFramework\Fixture\DataFixture;
20+
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
21+
use Magento\TestFramework\Helper\Bootstrap;
22+
use Magento\TestFramework\TestCase\GraphQlAbstract;
23+
use Magento\TestFramework\TestCase\HttpClient\CurlClient;
24+
25+
/**
26+
* Test customer authentication responses
27+
*/
28+
class AuthenticationTest extends GraphQlAbstract
29+
{
30+
private const QUERY_ACCESSIBLE_BY_GUEST = <<<QUERY
31+
{
32+
isEmailAvailable(email: "[email protected]") {
33+
is_email_available
34+
}
35+
}
36+
QUERY;
37+
38+
private const QUERY_REQUIRE_AUTHENTICATION = <<<QUERY
39+
{
40+
customer {
41+
email
42+
}
43+
}
44+
QUERY;
45+
46+
/**
47+
* @var CustomerTokenServiceInterface
48+
*/
49+
private $tokenService;
50+
51+
protected function setUp(): void
52+
{
53+
$this->tokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class);
54+
}
55+
56+
/**
57+
* @throws Exception
58+
*/
59+
public function testNoToken(): void
60+
{
61+
self::assertEquals(
62+
[
63+
'isEmailAvailable' => [
64+
'is_email_available' => 1
65+
]
66+
],
67+
$this->graphQlQuery(self::QUERY_ACCESSIBLE_BY_GUEST)
68+
);
69+
}
70+
71+
public function testInvalidToken(): void
72+
{
73+
$this->expectExceptionCode(401);
74+
Bootstrap::getObjectManager()->get(CurlClient::class)->get(
75+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
76+
[
77+
'query' => self::QUERY_ACCESSIBLE_BY_GUEST
78+
],
79+
[
80+
'Authorization: Bearer invalid_token'
81+
]
82+
);
83+
}
84+
85+
/**
86+
* @throws AuthenticationException
87+
* @throws LocalizedException
88+
* @throws EmailNotConfirmedException
89+
*/
90+
#[
91+
DataFixture(Customer::class, as: 'customer'),
92+
]
93+
public function testRevokedTokenPublicQuery(): void
94+
{
95+
/** @var CustomerInterface $customer */
96+
$customer = DataFixtureStorageManager::getStorage()->get('customer');
97+
$token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password');
98+
99+
self::assertEquals(
100+
[
101+
'isEmailAvailable' => [
102+
'is_email_available' => 1
103+
]
104+
],
105+
$this->graphQlQuery(
106+
self::QUERY_ACCESSIBLE_BY_GUEST,
107+
[],
108+
'',
109+
[
110+
'Authorization' => 'Bearer ' . $token
111+
]
112+
)
113+
);
114+
115+
$this->tokenService->revokeCustomerAccessToken($customer->getId());
116+
117+
$this->expectExceptionCode(401);
118+
Bootstrap::getObjectManager()->get(CurlClient::class)->get(
119+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
120+
[
121+
'query' => self::QUERY_ACCESSIBLE_BY_GUEST
122+
],
123+
[
124+
'Authorization: Bearer ' . $token
125+
]
126+
);
127+
}
128+
129+
/**
130+
* @throws AuthenticationException
131+
* @throws EmailNotConfirmedException
132+
* @throws LocalizedException
133+
* @throws Exception
134+
*/
135+
#[
136+
DataFixture(Customer::class, as: 'customer'),
137+
]
138+
public function testRevokedTokenProtectedQuery()
139+
{
140+
/** @var CustomerInterface $customer */
141+
$customer = DataFixtureStorageManager::getStorage()->get('customer');
142+
$token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password');
143+
144+
self::assertEquals(
145+
[
146+
'customer' => [
147+
'email' => $customer->getEmail()
148+
]
149+
],
150+
$this->graphQlQuery(
151+
self::QUERY_REQUIRE_AUTHENTICATION,
152+
[],
153+
'',
154+
[
155+
'Authorization' => 'Bearer ' . $token
156+
]
157+
)
158+
);
159+
160+
$this->tokenService->revokeCustomerAccessToken($customer->getId());
161+
162+
$this->expectExceptionCode(401);
163+
Bootstrap::getObjectManager()->get(CurlClient::class)->get(
164+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
165+
[
166+
'query' => self::QUERY_REQUIRE_AUTHENTICATION
167+
],
168+
[
169+
'Authorization: Bearer ' . $token
170+
]
171+
);
172+
}
173+
174+
/**
175+
* @throws NoSuchEntityException
176+
* @throws AuthenticationException
177+
* @throws EmailNotConfirmedException
178+
* @throws LocalizedException
179+
*/
180+
#[
181+
DataFixture(Customer::class, as: 'unauthorizedCustomer'),
182+
DataFixture(
183+
Customer::class,
184+
[
185+
'addresses' => [
186+
[
187+
'country_id' => 'US',
188+
'region_id' => 32,
189+
'city' => 'Boston',
190+
'street' => ['10 Milk Street'],
191+
'postcode' => '02108',
192+
'telephone' => '1234567890',
193+
'default_billing' => true,
194+
'default_shipping' => true
195+
]
196+
]
197+
],
198+
as: 'customerWithAddress'
199+
),
200+
]
201+
public function testForbidden(): void
202+
{
203+
/** @var CustomerInterface $customerWithAddress */
204+
$customerWithAddressData = DataFixtureStorageManager::getStorage()->get('customerWithAddress');
205+
$customerWithAddress = Bootstrap::getObjectManager()
206+
->get(CustomerRepositoryInterface::class)
207+
->get($customerWithAddressData->getEmail());
208+
$mutation = <<<MUTATION
209+
mutation {
210+
deleteCustomerAddress(id: {$customerWithAddress->getDefaultBilling()})
211+
}
212+
MUTATION;
213+
214+
/** @var CustomerInterface $unauthorizedCustomer */
215+
$unauthorizedCustomer = DataFixtureStorageManager::getStorage()->get('unauthorizedCustomer');
216+
$token = $this->tokenService->createCustomerAccessToken($unauthorizedCustomer->getEmail(), 'password');
217+
218+
$this->expectExceptionCode(403);
219+
Bootstrap::getObjectManager()->get(CurlClient::class)->post(
220+
rtrim(TESTS_BASE_URL, '/') . '/graphql',
221+
json_encode(['query' => $mutation]),
222+
[
223+
'Authorization: Bearer ' . $token,
224+
'Accept: application/json',
225+
'Content-Type: application/json'
226+
]
227+
);
228+
}
229+
}

0 commit comments

Comments
 (0)