Skip to content

Commit 68ec43d

Browse files
authored
Merge pull request #480 from opcodesio/fix/api-auth-empty-app-url
Fix API authentication failure when APP_URL is empty
2 parents 02ba4e3 + 680c156 commit 68ec43d

File tree

3 files changed

+276
-3
lines changed

3 files changed

+276
-3
lines changed

src/Http/Middleware/EnsureFrontendRequestsAreStateful.php

Lines changed: 48 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,54 @@ 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+
protected static function resolveMiddleware(string $configKey, string $default): string
119+
{
120+
$middleware = config($configKey, $default);
121+
122+
return class_exists($middleware) ? $middleware : $default;
78123
}
79124

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

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)