Deploy Ladill Queue / deploy (push) Successful in 1m32s
Demo Free/Pro/Enterprise accounts wipe and reload sample data on authorization-code SSO callbacks so every sales walkthrough starts clean. Co-authored-by: Cursor <cursoragent@cursor.com>
325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Services\Identity\IdentityTeamClient;
|
|
use App\Services\Qms\TeamMemberProvisioner;
|
|
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\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
|
|
{
|
|
/**
|
|
* Max consecutive failed callbacks before we stop bouncing back into the
|
|
* authorize flow and show an error page instead (prevents an infinite
|
|
* /sso/callback ↔ /sso/connect redirect loop on a persistent upstream error).
|
|
*/
|
|
private const MAX_SSO_ATTEMPTS = 3;
|
|
|
|
public function connect(Request $request, IdentityTeamClient $identity): RedirectResponse|View
|
|
{
|
|
$intended = (string) $request->query('redirect', route('qms.dashboard'));
|
|
|
|
if (Auth::check()) {
|
|
$redirect = $this->resolveLanding($identity, $request->user(), $intended);
|
|
|
|
return $this->safeRedirect($redirect, route('qms.dashboard'));
|
|
}
|
|
|
|
if (! $request->boolean('fallback')) {
|
|
$request->session()->forget('sso.attempts');
|
|
}
|
|
|
|
if ($this->attemptSilentRefresh($request, $intended)) {
|
|
app(TeamMemberProvisioner::class)->sync(Auth::user());
|
|
$redirect = $this->resolveLanding($identity, Auth::user(), $intended);
|
|
|
|
return $this->safeRedirect($redirect, route('qms.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);
|
|
|
|
return redirect()->away($authorizeUrl);
|
|
}
|
|
|
|
public function callback(Request $request): RedirectResponse
|
|
{
|
|
$intended = (string) $request->session()->get('sso.intended', route('qms.dashboard'));
|
|
|
|
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')));
|
|
}
|
|
|
|
if (! $request->filled('code')
|
|
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
|
return $this->finishCallback($request, $intended, 'invalid_state');
|
|
}
|
|
|
|
$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');
|
|
}
|
|
|
|
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
|
if (! $user) {
|
|
return $this->finishCallback($request, $intended, 'userinfo_failed');
|
|
}
|
|
|
|
Auth::login($user, remember: true);
|
|
$request->session()->regenerate();
|
|
$request->session()->forget('sso.attempts');
|
|
|
|
app(TeamMemberProvisioner::class)->sync($user);
|
|
app(\App\Services\Qms\DemoLoginReseeder::class)->maybeResetAfterLogin($user);
|
|
|
|
return $this->finishCallback($request, $intended, null, app(IdentityTeamClient::class));
|
|
}
|
|
|
|
public function failed(Request $request): View
|
|
{
|
|
return view('auth.sso-error', [
|
|
'reason' => (string) $request->session()->get('sso.error', ''),
|
|
]);
|
|
}
|
|
|
|
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.
|
|
return redirect()->route('qms.signed-out');
|
|
}
|
|
|
|
/** Platform session ended — clear this app and offer silent sign-in again. */
|
|
public function platformSignedOut(Request $request): RedirectResponse
|
|
{
|
|
Auth::logout();
|
|
$request->session()->invalidate();
|
|
$request->session()->regenerateToken();
|
|
|
|
return redirect()->route('sso.connect', [
|
|
'redirect' => (string) $request->query('redirect', ''),
|
|
]);
|
|
}
|
|
|
|
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
|
|
{
|
|
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 finishCallback(
|
|
Request $request,
|
|
string $intended,
|
|
?string $error = null,
|
|
?IdentityTeamClient $identity = null,
|
|
): RedirectResponse {
|
|
if ($error) {
|
|
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
|
|
|
|
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
|
|
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
|
|
$request->session()->flash('sso.error', $error);
|
|
|
|
return redirect()->route('sso.failed');
|
|
}
|
|
|
|
$request->session()->put('sso.attempts', $attempts);
|
|
|
|
return redirect()->route('sso.connect', [
|
|
'redirect' => $intended,
|
|
'interactive' => 1,
|
|
'fallback' => 1,
|
|
]);
|
|
}
|
|
|
|
$user = Auth::user();
|
|
$redirect = ($user instanceof User && $identity)
|
|
? $this->resolveLanding($identity, $user, $intended)
|
|
: $intended;
|
|
|
|
return $this->safeRedirect($redirect, route('qms.dashboard'));
|
|
}
|
|
|
|
private function resolveLanding(IdentityTeamClient $identity, User $user, string $intended): string
|
|
{
|
|
try {
|
|
$access = $identity->appAccess($user->ownerRef(), $intended);
|
|
\App\Support\StaffUx::remember($access);
|
|
|
|
return $access['url'] !== '' ? $access['url'] : $intended;
|
|
} catch (\Throwable) {
|
|
return $intended;
|
|
}
|
|
}
|
|
|
|
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 route('qms.signed-out');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|