Skip to content

Commit 211a551

Browse files
committed
Fix API authentication failure when APP_URL is empty
Add same-domain fallback in EnsureFrontendRequestsAreStateful middleware to allow API requests when APP_URL is not configured. Previously, API routes failed authentication when referer domain didn't match APP_URL, causing Auth::check() to return false even for authenticated users.
1 parent 3d04bda commit 211a551

File tree

3 files changed

+257
-3
lines changed

3 files changed

+257
-3
lines changed

src/Http/Middleware/EnsureFrontendRequestsAreStateful.php

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ function ($request, $next) {
3030

3131
return $next($request);
3232
},
33-
config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class),
33+
static::resolveMiddleware('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class),
3434
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
3535
\Illuminate\Session\Middleware\StartSession::class,
36-
config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),
36+
static::resolveMiddleware('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),
3737
] : [])->then(function ($request) use ($next) {
3838
return $next($request);
3939
});
@@ -72,9 +72,58 @@ public static function fromFrontend($request)
7272

7373
$stateful = array_filter(config('log-viewer.api_stateful_domains') ?? config('sanctum.stateful') ?? self::defaultStatefulDomains());
7474

75-
return Str::is(Collection::make($stateful)->map(function ($uri) {
75+
$matchesStatefulDomains = Str::is(Collection::make($stateful)->map(function ($uri) {
7676
return trim($uri).'/*';
7777
})->all(), $domain);
78+
79+
if ($matchesStatefulDomains) {
80+
return true;
81+
}
82+
83+
// If APP_URL is not configured, allow same-domain requests as a fallback
84+
if (empty(config('app.url'))) {
85+
return self::isSameDomainRequest($request, $domain);
86+
}
87+
88+
return false;
89+
}
90+
91+
/**
92+
* Check if the referer/origin domain matches the current request's domain.
93+
*
94+
* @param \Illuminate\Http\Request $request
95+
* @param string $refererDomain
96+
* @return bool
97+
*/
98+
protected static function isSameDomainRequest($request, $refererDomain)
99+
{
100+
$currentHost = $request->getHost();
101+
$currentPort = $request->getPort();
102+
103+
// Build current domain with port if not default
104+
$currentDomain = $currentHost;
105+
if (! in_array($currentPort, [80, 443])) {
106+
$currentDomain .= ':'.$currentPort;
107+
}
108+
109+
// Extract host:port from referer domain (strip path)
110+
$refererHostPort = explode('/', $refererDomain)[0];
111+
112+
return $refererHostPort === $currentDomain;
113+
}
114+
115+
/**
116+
* Resolve middleware class from config with fallback.
117+
*
118+
* @param string $configKey
119+
* @param string $default
120+
* @return string
121+
*/
122+
protected static function resolveMiddleware(string $configKey, string $default): string
123+
{
124+
$middleware = config($configKey, $default);
125+
126+
return class_exists($middleware) ? $middleware : $default;
78127
}
79128

80129
protected static function defaultStatefulDomains(): array
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
<?php
2+
3+
use Opcodes\LogViewer\Facades\LogViewer;
4+
5+
use function Pest\Laravel\get;
6+
use function Pest\Laravel\getJson;
7+
8+
test('auth callback is called for API routes', function () {
9+
$callbackInvoked = false;
10+
11+
LogViewer::auth(function ($request) use (&$callbackInvoked) {
12+
$callbackInvoked = true;
13+
14+
return true;
15+
});
16+
17+
getJson(route('log-viewer.folders'))->assertOk();
18+
19+
expect($callbackInvoked)->toBeTrue();
20+
});
21+
22+
test('auth callback denies access to API routes when it returns false', function () {
23+
LogViewer::auth(fn ($request) => false);
24+
25+
getJson(route('log-viewer.folders'))->assertForbidden();
26+
});
27+
28+
test('authentication works when APP_URL is empty using same-domain fallback', function () {
29+
config(['app.url' => '']);
30+
31+
LogViewer::auth(fn ($request) => true);
32+
33+
$response = getJson(route('log-viewer.folders'), [
34+
'referer' => 'http://localhost/',
35+
]);
36+
37+
$response->assertOk();
38+
});
39+
40+
test('authentication works when APP_URL matches request domain', function () {
41+
config(['app.url' => 'http://example.com']);
42+
43+
LogViewer::auth(fn ($request) => true);
44+
45+
$response = getJson('http://example.com/log-viewer/api/folders', [
46+
'referer' => 'http://example.com/',
47+
]);
48+
49+
$response->assertOk();
50+
});
51+
52+
test('authentication fails when APP_URL is set but referer does not match', function () {
53+
config(['app.url' => 'http://configured-domain.com']);
54+
55+
// Auth callback that checks for authenticated user (simulating real-world usage)
56+
LogViewer::auth(function ($request) {
57+
// In real usage, Auth::user() would be null because session middleware isn't applied
58+
// For this test, we'll simulate that by checking if session is available
59+
if (! $request->hasSession() || ! $request->session()->isStarted()) {
60+
return false;
61+
}
62+
63+
return true;
64+
});
65+
66+
// Request from different domain with referer that doesn't match stateful domains
67+
$response = getJson('http://different-domain.com/log-viewer/api/folders', [
68+
'referer' => 'http://different-domain.com/',
69+
]);
70+
71+
// Should fail because session middleware is not applied, causing auth callback to return false
72+
$response->assertForbidden();
73+
});
74+
75+
test('same-domain requests work without APP_URL configured', function () {
76+
config(['app.url' => null]);
77+
78+
LogViewer::auth(fn ($request) => true);
79+
80+
// Simulate request from same domain
81+
$response = getJson('http://production.example.com/log-viewer/api/folders', [
82+
'referer' => 'http://production.example.com/log-viewer',
83+
]);
84+
85+
$response->assertOk();
86+
});
87+
88+
test('same-domain requests with custom port work without APP_URL', function () {
89+
config(['app.url' => null]);
90+
91+
LogViewer::auth(fn ($request) => true);
92+
93+
// Simulate request from same domain with custom port
94+
$response = getJson('http://localhost:8080/log-viewer/api/folders', [
95+
'referer' => 'http://localhost:8080/log-viewer',
96+
]);
97+
98+
$response->assertOk();
99+
});
100+
101+
test('cross-domain requests are rejected when APP_URL is empty', function () {
102+
config(['app.url' => null]);
103+
104+
// Auth callback that checks for session (simulating Auth::check() behavior)
105+
LogViewer::auth(function ($request) {
106+
if (! $request->hasSession() || ! $request->session()->isStarted()) {
107+
return false;
108+
}
109+
110+
return true;
111+
});
112+
113+
// Request to one domain with referer from different domain
114+
$response = getJson('http://domain-a.com/log-viewer/api/folders', [
115+
'referer' => 'http://domain-b.com/',
116+
]);
117+
118+
// Should fail because domains don't match, session middleware not applied
119+
$response->assertForbidden();
120+
});
121+
122+
test('requests without referer or origin are rejected', function () {
123+
config(['app.url' => null]);
124+
125+
// Auth callback that checks for session
126+
LogViewer::auth(function ($request) {
127+
if (! $request->hasSession() || ! $request->session()->isStarted()) {
128+
return false;
129+
}
130+
131+
return true;
132+
});
133+
134+
// Request without referer header
135+
$response = getJson(route('log-viewer.folders'));
136+
137+
// Should fail because we can't determine if it's from frontend, so session middleware not applied
138+
$response->assertForbidden();
139+
});
140+
141+
test('localhost requests work by default regardless of APP_URL', function () {
142+
config(['app.url' => 'http://production.com']);
143+
144+
LogViewer::auth(fn ($request) => true);
145+
146+
// Localhost is in the default stateful domains
147+
$response = getJson('http://localhost/log-viewer/api/folders', [
148+
'referer' => 'http://localhost/',
149+
]);
150+
151+
$response->assertOk();
152+
});
153+
154+
test('127.0.0.1 requests work by default regardless of APP_URL', function () {
155+
config(['app.url' => 'http://production.com']);
156+
157+
LogViewer::auth(fn ($request) => true);
158+
159+
// 127.0.0.1 is in the default stateful domains
160+
$response = getJson('http://127.0.0.1/log-viewer/api/folders', [
161+
'referer' => 'http://127.0.0.1/',
162+
]);
163+
164+
$response->assertOk();
165+
});
166+
167+
test('custom stateful domains override APP_URL behavior', function () {
168+
config([
169+
'app.url' => null,
170+
'log-viewer.api_stateful_domains' => ['custom-domain.com'],
171+
]);
172+
173+
LogViewer::auth(fn ($request) => true);
174+
175+
// Custom domain should work
176+
$response = getJson('http://custom-domain.com/log-viewer/api/folders', [
177+
'referer' => 'http://custom-domain.com/',
178+
]);
179+
180+
$response->assertOk();
181+
});

tests/Feature/Authorization/CanViewLogViewerTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use Opcodes\LogViewer\Facades\LogViewer;
55

66
use function Pest\Laravel\get;
7+
use function Pest\Laravel\getJson;
78

89
test('can define an "auth" callback for authorization', function () {
910
get(route('log-viewer.index'))->assertOk();
@@ -64,3 +65,26 @@
6465

6566
get(route('log-viewer.index'))->assertOk();
6667
});
68+
69+
test('auth callback works consistently for both web and API routes', function () {
70+
$webCalls = 0;
71+
$apiCalls = 0;
72+
73+
LogViewer::auth(function ($request) use (&$webCalls, &$apiCalls) {
74+
if ($request->is('log-viewer/api/*')) {
75+
$apiCalls++;
76+
} else {
77+
$webCalls++;
78+
}
79+
80+
return true;
81+
});
82+
83+
// Access web route
84+
get(route('log-viewer.index'))->assertOk();
85+
expect($webCalls)->toBe(1);
86+
87+
// Access API route
88+
getJson(route('log-viewer.folders'), ['referer' => 'http://localhost/'])->assertOk();
89+
expect($apiCalls)->toBe(1);
90+
});

0 commit comments

Comments
 (0)