From da8a8eee98278c1f3f19636dc2fcbef802eba0de Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Fri, 24 Jul 2026 17:59:10 +0200 Subject: [PATCH 1/6] Create API auth exception class --- src/Exceptions/ApiAuthenticationException.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/Exceptions/ApiAuthenticationException.php diff --git a/src/Exceptions/ApiAuthenticationException.php b/src/Exceptions/ApiAuthenticationException.php new file mode 100644 index 00000000000..7b537fd338a --- /dev/null +++ b/src/Exceptions/ApiAuthenticationException.php @@ -0,0 +1,17 @@ +json( + ['message' => $this->getMessage()], + 401, + ); + } +} From 349cdcc0dc0a65dcf2754008131a23f8afadc7d0 Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Fri, 24 Jul 2026 17:59:28 +0200 Subject: [PATCH 2/6] Send http header with authentication method --- src/Exceptions/ApiAuthenticationException.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Exceptions/ApiAuthenticationException.php b/src/Exceptions/ApiAuthenticationException.php index 7b537fd338a..2987ad8d97f 100644 --- a/src/Exceptions/ApiAuthenticationException.php +++ b/src/Exceptions/ApiAuthenticationException.php @@ -12,6 +12,7 @@ public function toResponse($request) return response()->json( ['message' => $this->getMessage()], 401, + ['WWW-Authenticate' => 'Bearer'], ); } } From 9b17e9dba27e3610c820cc0400b134a33dbacd48 Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Fri, 24 Jul 2026 17:59:42 +0200 Subject: [PATCH 3/6] Throw api auth exception from api middleware --- src/API/Middleware/HandleAuthentication.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/API/Middleware/HandleAuthentication.php b/src/API/Middleware/HandleAuthentication.php index c40b890a4ce..65c0a1faa63 100644 --- a/src/API/Middleware/HandleAuthentication.php +++ b/src/API/Middleware/HandleAuthentication.php @@ -3,6 +3,7 @@ namespace Statamic\API\Middleware; use Closure; +use Statamic\Exceptions\ApiAuthenticationException; class HandleAuthentication { @@ -18,7 +19,7 @@ public function handle($request, Closure $next) ($token = config('statamic.api.auth_token')) && ($request->bearerToken() !== $token) ) { - abort(401); + throw new ApiAuthenticationException; } return $next($request); From add65f692ac5b1bdfc44b6e87e50459943356137 Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Fri, 24 Jul 2026 17:59:57 +0200 Subject: [PATCH 4/6] Add tests for usage of custom api auth exception --- tests/API/AuthenticationTest.php | 44 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/tests/API/AuthenticationTest.php b/tests/API/AuthenticationTest.php index 26914ab1998..72f1ea5332d 100644 --- a/tests/API/AuthenticationTest.php +++ b/tests/API/AuthenticationTest.php @@ -40,28 +40,50 @@ public function it_cant_authenticate_with_invalid_auth_token() Facades\Config::set('statamic.api.auth_token', 'foobar'); $this - ->withToken($token = 'invalid') - ->getJson($url = '/api/collections/articles/entries') - ->assertUnauthorized(); + ->withToken('invalid') + ->getJson('/api/collections/articles/entries') + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); + } + + #[Test] + public function it_cant_authenticate_without_auth_token() + { + Facades\Config::set('statamic.api.auth_token', 'foobar'); $this - ->withToken($token) - ->get($url) - ->assertUnauthorized(); + ->getJson('/api/collections/articles/entries') + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); } #[Test] - public function it_cant_authenticate_without_auth_token() + public function it_returns_the_same_json_response_when_debug_mode_is_enabled() { + Facades\Config::set('app.debug', true); Facades\Config::set('statamic.api.auth_token', 'foobar'); $this - ->getJson($url = '/api/collections/articles/entries') - ->assertUnauthorized(); + ->getJson('/api/collections/articles/entries') + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); + } + + #[Test] + public function it_returns_json_for_unauthenticated_requests() + { + Facades\Config::set('statamic.api.auth_token', 'foobar'); $this - ->get($url) - ->assertUnauthorized(); + ->withHeader('Accept', 'text/html') + ->get('/api/collections/articles/entries') + ->assertUnauthorized() + ->assertHeader('Content-Type', 'application/json') + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); } #[Test] From 86173d47bed67461a0c35dcfdba4f013f71cbf8a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 6 Aug 2026 15:15:31 -0400 Subject: [PATCH 5/6] Clarify name of test asserting json is returned for html requests --- tests/API/AuthenticationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/API/AuthenticationTest.php b/tests/API/AuthenticationTest.php index 72f1ea5332d..82d8dd42eea 100644 --- a/tests/API/AuthenticationTest.php +++ b/tests/API/AuthenticationTest.php @@ -73,7 +73,7 @@ public function it_returns_the_same_json_response_when_debug_mode_is_enabled() } #[Test] - public function it_returns_json_for_unauthenticated_requests() + public function it_returns_json_even_when_html_is_requested() { Facades\Config::set('statamic.api.auth_token', 'foobar'); From f14307472a4b807641a50b2f26513d5f722374e1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 6 Aug 2026 15:15:32 -0400 Subject: [PATCH 6/6] Throw api auth exception from graphql middleware --- .../Middleware/HandleAuthentication.php | 3 +- tests/Feature/GraphQL/AuthenticationTest.php | 44 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/GraphQL/Middleware/HandleAuthentication.php b/src/GraphQL/Middleware/HandleAuthentication.php index ca5ba4e5195..f8793d34058 100644 --- a/src/GraphQL/Middleware/HandleAuthentication.php +++ b/src/GraphQL/Middleware/HandleAuthentication.php @@ -3,6 +3,7 @@ namespace Statamic\GraphQL\Middleware; use Closure; +use Statamic\Exceptions\ApiAuthenticationException; class HandleAuthentication { @@ -18,7 +19,7 @@ public function handle($request, Closure $next) ($token = config('statamic.graphql.auth_token')) && ($request->bearerToken() !== $token) ) { - abort(401); + throw new ApiAuthenticationException; } return $next($request); diff --git a/tests/Feature/GraphQL/AuthenticationTest.php b/tests/Feature/GraphQL/AuthenticationTest.php index 9dd5f5ca0ef..b952321c795 100644 --- a/tests/Feature/GraphQL/AuthenticationTest.php +++ b/tests/Feature/GraphQL/AuthenticationTest.php @@ -30,28 +30,50 @@ public function it_cant_authenticate_with_invalid_auth_token() Config::set('statamic.graphql.auth_token', 'foobar'); $this - ->withToken($token = 'invalid') - ->postJson($url = '/graphql', ['query' => '{ping}']) - ->assertUnauthorized(); + ->withToken('invalid') + ->postJson('/graphql', ['query' => '{ping}']) + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); + } + + #[Test] + public function it_cant_authenticate_without_auth_token() + { + Config::set('statamic.graphql.auth_token', 'foobar'); $this - ->withToken($token) - ->post($url, ['query' => '{ping}']) - ->assertUnauthorized(); + ->postJson('/graphql', ['query' => '{ping}']) + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); } #[Test] - public function it_cant_authenticate_without_auth_token() + public function it_returns_the_same_json_response_when_debug_mode_is_enabled() { + Config::set('app.debug', true); Config::set('statamic.graphql.auth_token', 'foobar'); $this - ->postJson($url = '/graphql', ['query' => '{ping}']) - ->assertUnauthorized(); + ->postJson('/graphql', ['query' => '{ping}']) + ->assertUnauthorized() + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); + } + + #[Test] + public function it_returns_json_even_when_html_is_requested() + { + Config::set('statamic.graphql.auth_token', 'foobar'); $this - ->post($url, ['query' => '{ping}']) - ->assertUnauthorized(); + ->withHeader('Accept', 'text/html') + ->post('/graphql', ['query' => '{ping}']) + ->assertUnauthorized() + ->assertHeader('Content-Type', 'application/json') + ->assertHeader('WWW-Authenticate', 'Bearer') + ->assertExactJson(['message' => 'Unauthenticated.']); } #[Test]