Files
ladill-qr-plus/app/Http/Controllers/Auth/SsoLoginController.php
T
isaaccladandCursor c4e2c75324
Deploy Ladill QR Plus / deploy (push) Successful in 28s
Send SSO guests to ladill.com/products/qrplus instead of subdomain landings.
Remove interactive OAuth fallback on silent SSO failure and add ladill.marketing_url config so app subdomains open the dashboard when signed in locally.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 22:22:28 +00:00

313 lines
11 KiB
PHP

<?php
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.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('qr.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('qr.dashboard'));
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('qr.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'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';
}
$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|View
{
$intended = (string) $request->session()->get('sso.intended', route('qr.dashboard'));
$popup = (bool) $request->session()->get('sso.popup');
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
return $this->finishCallback($request, $intended, null, $popup);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Per-app sign-out: end only this app's session and keep the platform
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently
// without a password. Full "sign out of all Ladill apps" lives on account.
return redirect()->away($this->defaultSignedOutUrl());
}
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
{
if ($this->shouldLogoutForMailbox($request)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
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', '')));
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
return true;
}
$user = Auth::user();
if (! $user) {
return false;
}
return strtolower((string) $user->email) !== $mailbox;
}
private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View
{
if ($popup) {
return view('auth.sso-popup-done', [
'intended' => $intended,
'error' => $error,
'appOrigin' => rtrim((string) config('app.url'), '/'),
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
if ($error) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->safeRedirect($intended, route('qr.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);
$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 redirect()->away($url);
}
return redirect()->away($fallback);
}
}