Keep the app subdomain visible during Ladill SSO login and logout.
Deploy Ladill Hosting / deploy (push) Successful in 34s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-09 12:15:37 +00:00
co-authored by Cursor
parent a3496f8019
commit fedd01cdde
5 changed files with 259 additions and 36 deletions
+163 -36
View File
@@ -3,13 +3,17 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
@@ -17,7 +21,7 @@ use Illuminate\Support\Str;
*/
class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('hosting.dashboard'));
@@ -25,6 +29,10 @@ class SsoLoginController extends Controller
return $this->safeRedirect($intended, route('hosting.dashboard'));
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('hosting.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
@@ -43,14 +51,35 @@ class SsoLoginController extends Controller
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query));
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
$request->session()->put('sso.popup', true);
return view('auth.sso-signing-in', [
'authorizeUrl' => $authorizeUrl,
'intended' => $intended,
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse
public function callback(Request $request): RedirectResponse|View
{
$intended = (string) $request->session()->get('sso.intended', route('hosting.dashboard'));
@@ -63,18 +92,12 @@ class SsoLoginController extends Controller
]);
}
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
return $this->finishCallback($request, $intended, 'invalid_state');
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
@@ -88,36 +111,20 @@ class SsoLoginController extends Controller
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
return $this->finishCallback($request, $intended, 'token_exchange_failed');
}
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed');
}
$user = User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
\App\Models\HostingTeamMember::linkPendingInvitesFor($user);
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
$request->session()->forget('mailbox_link_banner_dismissed');
return $this->safeRedirect($intended, route('hosting.dashboard'));
return $this->finishCallback($request, $intended);
}
public function logout(Request $request): RedirectResponse
@@ -126,10 +133,24 @@ class SsoLoginController extends Controller
$request->session()->invalidate();
$request->session()->regenerateToken();
$home = 'https://'.config('app.platform_domain');
$endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home);
return redirect()->route('sso.logout-bridge', [
'return' => $this->defaultSignedOutUrl(),
]);
}
return redirect()->away($endSession);
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
@@ -150,6 +171,68 @@ class SsoLoginController extends Controller
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function shouldLogoutForMailbox(Request $request): bool
{
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
@@ -165,6 +248,50 @@ class SsoLoginController extends Controller
return strtolower((string) $user->email) !== $mailbox;
}
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse|View
{
if ($request->session()->pull('sso.popup')) {
return view('auth.sso-popup-done', [
'intended' => $intended,
'error' => $error,
'appOrigin' => rtrim((string) config('app.url'), '/'),
]);
}
if ($error) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->safeRedirect($intended, route('hosting.dashboard'));
}
private function defaultSignedOutUrl(): string
{
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
if (str_ends_with($name, '.signed-out')) {
return route($name);
}
}
return 'https://'.config('app.platform_domain');
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return $this->defaultSignedOutUrl();
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing out…</title>
@include('partials.favicon')
<meta name="robots" content="noindex">
<style>
body { font-family: system-ui, sans-serif; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; background: #f8fafc; color: #334155; }
</style>
</head>
<body>
<p>Signing you out of Ladill…</p>
<iframe src="{{ $hubUrl }}" hidden width="0" height="0" title=""></iframe>
<script>
window.addEventListener('message', function (event) {
if (event.origin !== @json($authOrigin)) return;
if (event.data && event.data.type === 'ladill:logout:done' && event.data.return) {
window.location.replace(event.data.return);
}
});
setTimeout(function () { window.location.replace(@json($return)); }, 5000);
</script>
</body>
</html>
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Signing in…</title>
<meta name="robots" content="noindex">
</head>
<body>
<script>
(function () {
var intended = @json($intended);
var appOrigin = @json($appOrigin);
@if ($error)
if (window.opener) {
window.opener.postMessage({ type: 'ladill:sso:error' }, appOrigin);
window.close();
} else {
window.location.replace(@json(route('sso.connect', ['redirect' => $intended, 'interactive' => 1, 'fallback' => 1])));
}
@else
if (window.opener) {
window.opener.postMessage({ type: 'ladill:sso:success', return: intended }, appOrigin);
window.close();
} else {
window.location.replace(intended);
}
@endif
})();
</script>
</body>
</html>
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signing in…</title>
@include('partials.favicon')
<meta name="robots" content="noindex">
<style>
body { font-family: system-ui, sans-serif; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; background: #f8fafc; color: #334155; }
</style>
</head>
<body>
<p>Signing you in with Ladill…</p>
<script>
(function () {
var intended = @json($intended);
var authorizeUrl = @json($authorizeUrl);
var fallbackUrl = @json($fallbackUrl);
window.addEventListener('message', function (event) {
if (event.origin !== window.location.origin) return;
if (!event.data) return;
if (event.data.type === 'ladill:sso:success') {
window.location.replace(event.data.return || intended);
} else if (event.data.type === 'ladill:sso:error') {
window.location.replace(fallbackUrl);
}
});
var popup = window.open(authorizeUrl, 'ladill_sso', 'width=520,height=720');
if (!popup) {
window.location.replace(fallbackUrl);
}
})();
</script>
</body>
</html>
+1
View File
@@ -26,6 +26,7 @@ Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback');
Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('hosting.dashboard') : view('hosting.signed-out'))->name('hosting.signed-out');