diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index e18b849..1b57ba4 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -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); diff --git a/resources/views/auth/sso-logout-bridge.blade.php b/resources/views/auth/sso-logout-bridge.blade.php new file mode 100644 index 0000000..2540367 --- /dev/null +++ b/resources/views/auth/sso-logout-bridge.blade.php @@ -0,0 +1,26 @@ + + +
+ + +Signing you out of Ladill…
+ + + + diff --git a/resources/views/auth/sso-popup-done.blade.php b/resources/views/auth/sso-popup-done.blade.php new file mode 100644 index 0000000..9b3b877 --- /dev/null +++ b/resources/views/auth/sso-popup-done.blade.php @@ -0,0 +1,31 @@ + + + + +Signing you in with Ladill…
+ + + diff --git a/routes/web.php b/routes/web.php index 8c7dca2..d4077f5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');