singleloginsystem/app/Http/Controllers/SamlIdpController.php
= 4849736c60 Feat: add intials SAML SSO compatibility.
- SAML public certificate generation
- Authentication using SAML
2026-05-27 05:49:36 +00:00

151 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\SamlIdpService;
use Illuminate\Http\{Request, Response};
use Illuminate\Support\Facades\{Auth, Log};
use Throwable;
class SamlIdpController extends Controller
{
public function __construct(private readonly SamlIdpService $samlService) {}
/**
* SAML 2.0 Single Sign-On (SSO) Endpoint.
* Handles both GET (Redirect binding) and POST (POST binding) requests.
*/
public function sso(Request $request)
{
// 1. Retrieve SAMLRequest and RelayState (from request parameters or session fallback)
$samlRequest =
$request->input('SAMLRequest') ?? session('saml_pending_request');
$relayState =
$request->input('RelayState') ??
session('saml_pending_relay_state');
if (empty($samlRequest)) {
Log::warning('SAML SSO accessed without a SAMLRequest.');
abort(400, 'Missing SAMLRequest parameter.');
}
try {
// 2. Parse the SAMLRequest XML
$parsed = $this->samlService->parseRequest($samlRequest);
} catch (Throwable $e) {
Log::error('Failed to parse SAMLRequest: '.$e->getMessage());
abort(400, 'Invalid SAMLRequest: '.$e->getMessage());
}
// 3. Find corresponding Connected App by issuer (SAML Entity ID)
$connectedApp = $this->samlService->findConnectedApp($parsed['issuer']);
if (! $connectedApp) {
Log::warning(
"SAML SP Issuer [{$parsed['issuer']}] is not registered.",
);
abort(
403,
"The Service Provider [{$parsed['issuer']}] is not authorized in this system.",
);
}
if ('disconnected' === $connectedApp->status?->name) {
Log::warning(
"SAML SP Issuer [{$parsed['issuer']}] is registered but disconnected.",
);
abort(
403,
"The Service Provider [{$connectedApp->name}] is currently deactivated.",
);
}
/**
* @var array{
* saml?: array{
* entity_id?: string,
* acs_url?: string
* }
* } $settings
*/
$settings = $connectedApp->settings;
// Use the ACS URL from the request, falling back to the registered ACS URL
$acsUrl = $parsed['acsUrl'] ?: $settings['saml']['acs_url'] ?? null;
if (empty($acsUrl)) {
Log::error(
"No Assertion Consumer Service (ACS) URL defined for [{$connectedApp->name}].",
);
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
}
// 4. Authenticate the User
if (! Auth::check()) {
// Store the SAML details in session to complete flow post-login
session([
'saml_pending_request' => $samlRequest,
'saml_pending_relay_state' => $relayState,
]);
return redirect()->route('login');
}
// 5. Generate Signed SAML Response
$user = Auth::user();
try {
/** @var \App\Models\User $user */
$samlResponse = $this->samlService->generateResponse(
$user,
$connectedApp,
$parsed['requestId'],
$acsUrl,
);
} catch (Throwable $e) {
Log::error('Failed to generate SAML Response: '.$e->getMessage());
abort(500, 'Cryptographic signing of SAML Response failed.');
}
// 6. Clear pending SAML request from session
session()->forget(['saml_pending_request', 'saml_pending_relay_state']);
// 7. Return HTTP-POST Auto-Submission Page
return view('saml.post_response', [
'appName' => $connectedApp->name,
'acsUrl' => $acsUrl,
'samlResponse' => $samlResponse,
'relayState' => $relayState,
]);
}
/**
* SAML 2.0 Identity Provider (IdP) Metadata Endpoint.
* Exposes public keys and bindings to Service Providers like Microsoft Entra ID.
*/
public function metadata(): Response
{
$idpEntityId = route('saml.metadata');
$ssoUrl = route('saml.sso');
try {
$metadataXml = $this->samlService->generateMetadata(
$idpEntityId,
$ssoUrl,
);
} catch (Throwable $e) {
Log::error(
'Failed to generate SAML IdP Metadata: '.$e->getMessage(),
);
abort(
500,
'Failed to retrieve Identity Provider cryptographic identity.',
);
}
return response($metadataXml, 200, [
'Content-Type' => 'application/xml; charset=utf-8',
]);
}
}