From 4849736c6055fcd83c83bcecf5ed14fa5344271e Mon Sep 17 00:00:00 2001 From: = Date: Wed, 27 May 2026 05:49:36 +0000 Subject: [PATCH] Feat: add intials SAML SSO compatibility. - SAML public certificate generation - Authentication using SAML --- .../Commands/GenerateSamlKeysCommand.php | 115 +++++ app/Data/ConnectedApp/ConnectAppRequest.php | 1 + app/Data/ConnectedApp/ConnectedAppData.php | 1 + .../ResolveDashboardRouteController.php | 4 + app/Http/Controllers/SamlIdpController.php | 150 +++++++ app/Livewire/Forms/StoreConnectedAppFrom.php | 34 +- app/Models/ConnectedApp.php | 13 + app/Models/User.php | 2 +- app/Services/SamlIdpService.php | 396 ++++++++++++++++++ composer.json | 7 +- composer.lock | 44 +- ...0_add_settings_to_connected_apps_table.php | 30 ++ ...180001_add_immutable_id_to_users_table.php | 30 ++ phpstan.neon | 1 - .../pages/connected-apps/⚡create.blade.php | 80 +++- .../pages/connected-apps/⚡edit.blade.php | 58 ++- resources/views/saml/post_response.blade.php | 85 ++++ routes/web.php | 5 +- tests/Feature/SamlIdpTest.php | 113 +++++ 19 files changed, 1121 insertions(+), 48 deletions(-) create mode 100644 app/Console/Commands/GenerateSamlKeysCommand.php create mode 100644 app/Http/Controllers/SamlIdpController.php create mode 100644 app/Services/SamlIdpService.php create mode 100644 database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php create mode 100644 database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php create mode 100644 resources/views/saml/post_response.blade.php create mode 100644 tests/Feature/SamlIdpTest.php diff --git a/app/Console/Commands/GenerateSamlKeysCommand.php b/app/Console/Commands/GenerateSamlKeysCommand.php new file mode 100644 index 0000000..7583774 --- /dev/null +++ b/app/Console/Commands/GenerateSamlKeysCommand.php @@ -0,0 +1,115 @@ +option('force')) { + $this->info('SAML keys already exist. Use --force to overwrite them.'); + + return self::SUCCESS; + } + + $this->info('Generating SAML keys...'); + + $config = [ + 'digest_alg' => 'sha256', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + + // Create keypair + $res = openssl_pkey_new($config); + if (false === $res) { + $this->error('Failed to generate private key. Ensure OpenSSL is configured.'); + + return self::FAILURE; + } + + // Export private key + if (! openssl_pkey_export($res, $privKey)) { + $this->error('Failed to export private key.'); + + return self::FAILURE; + } + + // Generate CSR and self-signed certificate + $dn = [ + 'countryName' => 'US', + 'stateOrProvinceName' => 'California', + 'localityName' => 'San Francisco', + 'organizationName' => 'Laravel SSO', + 'organizationalUnitName' => 'IT Department', + 'commonName' => 'laravel-sso-idp', + 'emailAddress' => 'admin@sso.local', + ]; + + $csr = openssl_csr_new($dn, $res, ['digest_alg' => 'sha256']); + if (false === $csr) { + $this->error('Failed to create CSR.'); + + return self::FAILURE; + } + + $cert = openssl_csr_sign($csr, null, $res, 3650, ['digest_alg' => 'sha256']); // 10 years validity + if (false === $cert) { + $this->error('Failed to sign certificate.'); + + return self::FAILURE; + } + + if (! openssl_x509_export($cert, $certOut)) { + $this->error('Failed to export certificate.'); + + return self::FAILURE; + } + + // Write files + File::put($keyPath, $privKey); + File::put($certPath, $certOut); + + // Update or add .gitignore inside the saml directory + $gitignorePath = "{$directory}/.gitignore"; + if (! File::exists($gitignorePath)) { + File::put($gitignorePath, "idp.key\n"); + } + + $this->info('Keys generated successfully:'); + $this->line("- Private Key: {$keyPath}"); + $this->line("- Certificate: {$certPath}"); + + return self::SUCCESS; + } +} diff --git a/app/Data/ConnectedApp/ConnectAppRequest.php b/app/Data/ConnectedApp/ConnectAppRequest.php index db4607d..518d8e7 100644 --- a/app/Data/ConnectedApp/ConnectAppRequest.php +++ b/app/Data/ConnectedApp/ConnectAppRequest.php @@ -17,5 +17,6 @@ public function __construct( public int $connectionProtocolId, public ?string $slug = null, public ?int $connectionStatusId = null, + public ?array $settings = null, ) {} } diff --git a/app/Data/ConnectedApp/ConnectedAppData.php b/app/Data/ConnectedApp/ConnectedAppData.php index d53b683..658b532 100644 --- a/app/Data/ConnectedApp/ConnectedAppData.php +++ b/app/Data/ConnectedApp/ConnectedAppData.php @@ -19,5 +19,6 @@ public function __construct( #[MapInputName('connection_status_id')] public int $statusId, public ?string $slug = null, + public ?array $settings = null, ) {} } diff --git a/app/Http/Controllers/ResolveDashboardRouteController.php b/app/Http/Controllers/ResolveDashboardRouteController.php index dc8fe40..cbdfb94 100644 --- a/app/Http/Controllers/ResolveDashboardRouteController.php +++ b/app/Http/Controllers/ResolveDashboardRouteController.php @@ -11,6 +11,10 @@ class ResolveDashboardRouteController extends Controller { public function __invoke(Request $request) { + if (session()->has('saml_pending_request')) { + return redirect()->route('saml.sso'); + } + $user = $request->user(); $route = DashboardRoute::resolve($user->getRoleNames()); diff --git a/app/Http/Controllers/SamlIdpController.php b/app/Http/Controllers/SamlIdpController.php new file mode 100644 index 0000000..f45a0bb --- /dev/null +++ b/app/Http/Controllers/SamlIdpController.php @@ -0,0 +1,150 @@ +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', + ]); + } +} diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index 0fe66e9..6d9cee4 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -6,23 +6,22 @@ use App\Data\ConnectedApp\ConnectedAppData; use App\Services\ConnectedAppService; -use Livewire\Attributes\Validate; use Livewire\Form; class StoreConnectedAppFrom extends Form { - #[Validate('required|string|max:255|min:3')] public string $name = ''; - #[Validate('required|numeric|min:1|exists:connection_providers,id')] public int $providerId = 0; - #[Validate('required|numeric|min:1|exists:connection_protocols,id')] public int $protocolId = 0; - #[Validate('required|numeric|min:1|exists:connection_statuses,id')] public int $statusId = 0; + public string $samlEntityId = ''; + + public string $samlAcsUrl = ''; + protected ConnectedAppService $service; public function boot(ConnectedAppService $service): void @@ -30,6 +29,29 @@ public function boot(ConnectedAppService $service): void $this->service = $service; } + /** + * Get the dynamic validation rules for the form. + * + * @return array + */ + public function rules(): array + { + $rules = [ + 'name' => 'required|string|max:255|min:3', + 'providerId' => 'required|numeric|min:1|exists:connection_providers,id', + 'protocolId' => 'required|numeric|min:1|exists:connection_protocols,id', + 'statusId' => 'required|numeric|min:1|exists:connection_statuses,id', + ]; + + $protocol = \App\Models\ConnectionProtocol::find($this->protocolId); + if ($protocol && 'saml' === mb_strtolower($protocol->name)) { + $rules['samlEntityId'] = 'required|string|min:3'; + $rules['samlAcsUrl'] = 'required|url'; + } + + return $rules; + } + /** * This sets the form to edit mode, after fetching the connected app of * the given id. @@ -40,5 +62,7 @@ public function set(ConnectedAppData $data): void $this->protocolId = $data->protocolId; $this->providerId = $data->providerId; $this->statusId = $data->statusId; + $this->samlEntityId = $data->settings['saml']['entity_id'] ?? ''; + $this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? ''; } } diff --git a/app/Models/ConnectedApp.php b/app/Models/ConnectedApp.php index 4157f86..6ebd9af 100644 --- a/app/Models/ConnectedApp.php +++ b/app/Models/ConnectedApp.php @@ -22,11 +22,24 @@ 'connection_protocol_id', 'connection_provider_id', 'connection_status_id', + 'settings', ])] class ConnectedApp extends Model { use HasFactory, LogsActivity, SoftDeletes; + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'settings' => 'array', + ]; + } + /** * @return HasOne */ diff --git a/app/Models/User.php b/app/Models/User.php index 2d1c96f..cf750d5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -15,7 +15,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; use Spatie\Permission\Traits\HasRoles; -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['name', 'email', 'password', 'immutable_id'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] final class User extends Authenticatable { diff --git a/app/Services/SamlIdpService.php b/app/Services/SamlIdpService.php new file mode 100644 index 0000000..98032b1 --- /dev/null +++ b/app/Services/SamlIdpService.php @@ -0,0 +1,396 @@ +preserveWhiteSpace = false; + if (! @$dom->loadXML($xml)) { + throw new Exception('Failed to load SAMLRequest XML.'); + } + + $issuerNode = $dom + ->getElementsByTagNameNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'Issuer', + ) + ->item(0); + if (! $issuerNode) { + $issuerNode = $dom->getElementsByTagName('Issuer')->item(0); + } + $issuer = $issuerNode ? mb_trim($issuerNode->nodeValue) : ''; + + $requestId = $dom->documentElement->getAttribute('ID'); + $acsUrl = $dom->documentElement->getAttribute( + 'AssertionConsumerServiceURL', + ); + + return [ + 'requestId' => $requestId, + 'issuer' => $issuer, + 'acsUrl' => $acsUrl, + ]; + } + + /** + * Verify if the SAML SP Issuer is registered as a connected app and active. + */ + public function findConnectedApp(string $issuer): ?ConnectedApp + { + return ConnectedApp::query() + ->where('settings->saml->entity_id', $issuer) + ->first(); + } + + /** + * Generate the XML SAML metadata for this Identity Provider. + */ + public function generateMetadata( + string $idpEntityId, + string $ssoUrl, + ): string { + $certPem = $this->getPublicCertificate(); + $certClean = $this->cleanPem($certPem); + + $xml = << + + + + + + {$certClean} + + + + + + + + XML; + + return $xml; + } + + /** + * Generate a signed SAMLResponse XML for a successful authentication. + * + * @throws Exception + */ + public function generateResponse( + User $user, + ConnectedApp $app, + string $requestId, + string $acsUrl, + ): string { + $idpEntityId = route('saml.metadata'); + /** + * @var array{ + * saml?: array{ + * entity_id?: string, + * acs_url?: string + * } + * } $settings + */ + $settings = (array) $app->settings; + $spEntityId = $settings['saml']['entity_id'] ?? $acsUrl; + + $responseId = '_'.Str::uuid()->toString(); + $assertionId = '_'.Str::uuid()->toString(); + + $now = time(); + $issueInstant = gmdate("Y-m-d\TH:i:s\Z", $now); + $notBefore = gmdate("Y-m-d\TH:i:s\Z", $now - 30); // 30 sec skew tolerance + $expireInstant = gmdate("Y-m-d\TH:i:s\Z", $now + 300); // 5 min validity + + $immutableId = $user->immutable_id ?? base64_encode((string) $user->id); + + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = false; + + $response = $dom->createElementNS( + 'urn:oasis:names:tc:SAML:2.0:protocol', + 'samlp:Response', + ); + $response->setAttribute('ID', $responseId); + $response->setAttribute('InResponseTo', $requestId); + $response->setAttribute('Version', '2.0'); + $response->setAttribute('IssueInstant', $issueInstant); + $response->setAttribute('Destination', $acsUrl); + + $issuer = $dom->createElementNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:Issuer', + $idpEntityId, + ); + $response->appendChild($issuer); + + $status = $dom->createElement('samlp:Status'); + $statusCode = $dom->createElement('samlp:StatusCode'); + $statusCode->setAttribute( + 'Value', + 'urn:oasis:names:tc:SAML:2.0:status:Success', + ); + $status->appendChild($statusCode); + $response->appendChild($status); + + $assertion = $dom->createElementNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:Assertion', + ); + $assertion->setAttribute('ID', $assertionId); + $assertion->setAttribute('Version', '2.0'); + $assertion->setAttribute('IssueInstant', $issueInstant); + + $assertionIssuer = $dom->createElement('saml:Issuer', $idpEntityId); + $assertion->appendChild($assertionIssuer); + + // Subject + $subject = $dom->createElement('saml:Subject'); + $nameId = $dom->createElement('saml:NameID', $immutableId); + $nameId->setAttribute( + 'Format', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + ); + $subject->appendChild($nameId); + + $subjectConfirmation = $dom->createElement('saml:SubjectConfirmation'); + $subjectConfirmation->setAttribute( + 'Method', + 'urn:oasis:names:tc:SAML:2.0:cm:bearer', + ); + $subjectConfirmationData = $dom->createElement( + 'saml:SubjectConfirmationData', + ); + $subjectConfirmationData->setAttribute('InResponseTo', $requestId); + $subjectConfirmationData->setAttribute('NotOnOrAfter', $expireInstant); + $subjectConfirmationData->setAttribute('Recipient', $acsUrl); + $subjectConfirmation->appendChild($subjectConfirmationData); + $subject->appendChild($subjectConfirmation); + $assertion->appendChild($subject); + + // Conditions + $conditions = $dom->createElement('saml:Conditions'); + $conditions->setAttribute('NotBefore', $notBefore); + $conditions->setAttribute('NotOnOrAfter', $expireInstant); + $audienceRestriction = $dom->createElement('saml:AudienceRestriction'); + $audience = $dom->createElement('saml:Audience', $spEntityId); + $audienceRestriction->appendChild($audience); + $conditions->appendChild($audienceRestriction); + $assertion->appendChild($conditions); + + // AuthnStatement + $authnStatement = $dom->createElement('saml:AuthnStatement'); + $authnStatement->setAttribute('AuthnInstant', $issueInstant); + $authnStatement->setAttribute('SessionIndex', $assertionId); + $authnContext = $dom->createElement('saml:AuthnContext'); + $authnContextClassRef = $dom->createElement( + 'saml:AuthnContextClassRef', + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ); + $authnContext->appendChild($authnContextClassRef); + $authnStatement->appendChild($authnContext); + $assertion->appendChild($authnStatement); + + // AttributeStatement (SAML claims) + $attributeStatement = $dom->createElement('saml:AttributeStatement'); + + // IDPEmail (essential claim for Office 365 mapping to UserPrincipalName) + $this->addAttribute( + $dom, + $attributeStatement, + 'IDPEmail', + $user->email, + ); + + // mail + $this->addAttribute($dom, $attributeStatement, 'mail', $user->email); + + // UPN + $this->addAttribute( + $dom, + $attributeStatement, + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + $user->email, + ); + + // displayName + $this->addAttribute( + $dom, + $attributeStatement, + 'displayName', + $user->name, + ); + + $assertion->appendChild($attributeStatement); + $response->appendChild($assertion); + $dom->appendChild($response); + + // Sign the assertion node + $this->signAssertion($dom, $assertion); + + return base64_encode($dom->saveXML()); + } + + /** + * Add a simple attribute to SAML AttributeStatement. + */ + private function addAttribute( + DOMDocument $dom, + DOMElement $statement, + string $name, + string $value, + ): void { + $attribute = $dom->createElement('saml:Attribute'); + $attribute->setAttribute('Name', $name); + $attribute->setAttribute( + 'NameFormat', + 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', + ); + $val = $dom->createElement( + 'saml:AttributeValue', + htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'), + ); + $attribute->appendChild($val); + $statement->appendChild($attribute); + } + + /** + * Cryptographically sign the Assertion node in DOMDocument. + */ + private function signAssertion( + DOMDocument $dom, + DOMElement|DOMDocument $assertion, + ): void { + $privateKeyPem = $this->getPrivateKey(); + $certPem = $this->getPublicCertificate(); + + // XML Security Signature Setup + $objDSig = new XMLSecurityDSig(); + $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); + + // Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly + $assertion->setIdAttribute('ID', true); + + // Add reference to assertion + $objDSig->addReference( + $assertion, + XMLSecurityDSig::SHA256, + [ + 'http://www.w3.org/2000/09/xmldsig#enveloped-signature', + 'http://www.w3.org/2001/10/xml-exc-c14n#', + ], + [ + 'id_name' => 'ID', + ], + ); + + $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, [ + 'type' => 'private', + ]); + $objKey->loadKey($privateKeyPem, false); + + // Sign the XML + $objDSig->sign($objKey, $assertion); + $objDSig->add509Cert($certPem); + + // Enforce SAML Schema order compliance: Signature node must precede Subject. + $signatureNode = $assertion + ->getElementsByTagNameNS( + 'http://www.w3.org/2000/09/xmldsig#', + 'Signature', + ) + ->item(0); + $subjectNode = $assertion + ->getElementsByTagNameNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'Subject', + ) + ->item(0); + + if ($signatureNode && $subjectNode) { + $assertion->insertBefore($signatureNode, $subjectNode); + } + } + + /** + * Load IdP Private Key. + */ + private function getPrivateKey(): string + { + $path = storage_path('app/saml/idp.key'); + if (! File::exists($path)) { + throw new RuntimeException( + "SAML private key not found at [{$path}]. Run 'php artisan saml:generate-keys'.", + ); + } + + return File::get($path); + } + + /** + * Load IdP Public Certificate. + */ + private function getPublicCertificate(): string + { + $path = storage_path('app/saml/idp.crt'); + if (! File::exists($path)) { + throw new RuntimeException( + "SAML public certificate not found at [{$path}]. Run 'php artisan saml:generate-keys'.", + ); + } + + return File::get($path); + } + + /** + * Helper to clean up PEM certificate for metadata insertion. + */ + private function cleanPem(string $pem): string + { + return str_replace( + [ + '-----BEGIN CERTIFICATE-----', + '-----END CERTIFICATE-----', + "\r", + "\n", + ' ', + ], + '', + $pem, + ); + } +} diff --git a/composer.json b/composer.json index ebf55e7..bf0e2e4 100644 --- a/composer.json +++ b/composer.json @@ -17,10 +17,12 @@ "livewire/flux": "^2.13.1", "livewire/livewire": "^4.3", "mallardduck/blade-lucide-icons": "^1.26", + "robrichards/xmlseclibs": "^3.1", "robsontenorio/mary": "^2.8", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-data": "^4.22", - "spatie/laravel-permission": "^7.4" + "spatie/laravel-permission": "^7.4", + "ext-openssl": "*" }, "require-dev": { "captainhook/captainhook": "^5.29", @@ -35,7 +37,8 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.9.3", "pestphp/pest": "^4.7", - "pestphp/pest-plugin-laravel": "^4.1" + "pestphp/pest-plugin-laravel": "^4.1", + "ext-zlib": "*" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 996be02..f60b855 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f3873c08b246822a39d28768c6205e67", + "content-hash": "63deb63c42b1c08194cd70e47ae3f312", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4659,6 +4659,48 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "robrichards/xmlseclibs", + "version": "3.1.5", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/03062be78178cbb5e8f605cd255dc32a14981f92", + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "support": { + "issues": "https://github.com/robrichards/xmlseclibs/issues", + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.5" + }, + "time": "2026-03-13T10:31:56+00:00" + }, { "name": "robsontenorio/mary", "version": "2.8.2", diff --git a/database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php b/database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php new file mode 100644 index 0000000..7fbb1d6 --- /dev/null +++ b/database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php @@ -0,0 +1,30 @@ +json('settings')->nullable()->after('connection_status_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('connected_apps', function (Blueprint $table): void { + $table->dropColumn('settings'); + }); + } +}; diff --git a/database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php b/database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php new file mode 100644 index 0000000..c1be534 --- /dev/null +++ b/database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php @@ -0,0 +1,30 @@ +string('immutable_id')->nullable()->unique()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('immutable_id'); + }); + } +}; diff --git a/phpstan.neon b/phpstan.neon index 45e9617..73581d5 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,7 +3,6 @@ includes: - vendor/nesbot/carbon/extension.neon parameters: - paths: - app/ - resources/views/ diff --git a/resources/views/pages/connected-apps/⚡create.blade.php b/resources/views/pages/connected-apps/⚡create.blade.php index b72c1ac..c3cdcac 100644 --- a/resources/views/pages/connected-apps/⚡create.blade.php +++ b/resources/views/pages/connected-apps/⚡create.blade.php @@ -6,16 +6,19 @@ use App\Enums\ConnectionStatusEnum; use App\Livewire\Forms\StoreConnectedAppFrom; use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus}; -use App\Services\{ConnectedAppService, ConnectionProtocolService, ConnectionProviderService, ConnectionStatusService}; +use App\Services\{ + ConnectedAppService, + ConnectionProtocolService, + ConnectionProviderService, + ConnectionStatusService, +}; use Illuminate\Support\Collection; use Livewire\Attributes\{Computed, Title}; use Livewire\Component; use Mary\Traits\Toast; use Spatie\LaravelData\DataCollection; -new -#[Title('Create a service')] -class extends Component { +new #[Title("Create a service")] class extends Component { use HandlesOperations; public StoreConnectedAppFrom $form; @@ -32,16 +35,17 @@ public function providers() return ConnectionProvider::query()->get(); } - #[Computed] public function protocols(): Collection { $protocolService = app(ConnectionProtocolService::class); - return $protocolService->getAll()->map(function (ConnectionProtocol $protocol) { - $protocol->name = strtoupper($protocol->name); - return $protocol; - }); + return $protocolService + ->getAll() + ->map(function (ConnectionProtocol $protocol) { + $protocol->name = strtoupper($protocol->name); + return $protocol; + }); } /** @@ -52,23 +56,47 @@ public function connectionStatuses(): DataCollection { $statusService = app(ConnectionStatusService::class); - return $statusService->getAll()->map(function (ConnectionStatusData $status) { - if ($status->name === ConnectionStatusEnum::Disconnected) { - $this->form->statusId = $status->id; - } - return $status; - }); + return $statusService + ->getAll() + ->map(function (ConnectionStatusData $status) { + if ($status->name === ConnectionStatusEnum::Disconnected) { + $this->form->statusId = $status->id; + } + return $status; + }); + } + + #[Computed] + public function isSaml(): bool + { + $protocol = $this->protocols()->firstWhere( + "id", + (int) $this->form->protocolId, + ); + return $protocol && strtolower($protocol->name) === "saml"; } public function save(bool $goBack = true): void { $this->form->validate(); + + $settings = null; + if ($this->isSaml()) { + $settings = [ + "saml" => [ + "entity_id" => $this->form->samlEntityId, + "acs_url" => $this->form->samlAcsUrl, + ], + ]; + } + $data = new ConnectAppRequest( name: $this->form->name, connectionProviderId: $this->form->providerId, connectionProtocolId: $this->form->protocolId, slug: str($this->form->name)->slug()->toString(), - connectionStatusId: $this->form->statusId + connectionStatusId: $this->form->statusId, + settings: $settings, ); $this->attempt( action: function () use ($data, $goBack) { @@ -77,18 +105,15 @@ public function save(bool $goBack = true): void if ($goBack) { $this->goBack(); } - } + }, ); } - public function create() - { - - } + public function create() {} public function goBack(): void { - $this->redirectRoute('apps.index', navigate: true); + $this->redirectRoute("apps.index", navigate: true); } }; ?> @@ -99,7 +124,7 @@ public function goBack(): void + + @if($this->isSaml) +
+

SAML Configuration

+ + +
+ @endif +
Cancel Save & Add Another diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/connected-apps/⚡edit.blade.php index 66a1641..d915422 100644 --- a/resources/views/pages/connected-apps/⚡edit.blade.php +++ b/resources/views/pages/connected-apps/⚡edit.blade.php @@ -19,11 +19,10 @@ use Livewire\Attributes\Title; use Mary\Traits\Toast; use Spatie\LaravelData\DataCollection; - -new -#[Lazy] -#[Title('Edit a connected app')] -class extends Component { +/** + * @property $protocols + */ +new #[Lazy] #[Title("Edit a connected app")] class extends Component { use HandlesOperations; public StoreConnectedAppFrom $form; @@ -44,9 +43,9 @@ public function mount(int $id): void $this->recordId = $id; }, onError: function () { - $this->redirectRoute('apps.index', navigate: true); + $this->redirectRoute("apps.index", navigate: true); }, - showSuccess: false + showSuccess: false, ); } @@ -56,16 +55,27 @@ public function providers() return ConnectionProvider::query()->get(); } - #[Computed] public function protocols(): Collection { $protocolService = app(ConnectionProtocolService::class); - return $protocolService->getAll()->map(function (ConnectionProtocol $protocol) { - $protocol->name = strtoupper($protocol->name); - return $protocol; - }); + return $protocolService + ->getAll() + ->map(function (ConnectionProtocol $protocol) { + $protocol->name = strtoupper($protocol->name); + return $protocol; + }); + } + + #[Computed] + public function isSaml(): bool + { + $protocol = $this->protocols()->firstWhere( + "id", + (int) $this->form->protocolId, + ); + return $protocol && strtolower($protocol->name) === "saml"; } /** @@ -84,15 +94,27 @@ public function save(): void $this->attempt( action: function () { $this->form->validate(); + + $settings = null; + if ($this->isSaml()) { + $settings = [ + "saml" => [ + "entity_id" => $this->form->samlEntityId, + "acs_url" => $this->form->samlAcsUrl, + ], + ]; + } + $serviceData = new ConnectAppRequest( name: $this->form->name, connectionProviderId: $this->form->providerId, connectionProtocolId: $this->form->protocolId, slug: str($this->form->name)->slug()->toString(), connectionStatusId: $this->form->statusId, + settings: $settings, ); $this->service->update($this->recordId, $serviceData); - $this->redirectRoute('apps.index', navigate: true); + $this->redirectRoute("apps.index", navigate: true); }, ); } @@ -107,7 +129,7 @@ public function save(): void + + @if($this->isSaml) +
+

SAML Configuration

+ + +
+ @endif
diff --git a/resources/views/saml/post_response.blade.php b/resources/views/saml/post_response.blade.php new file mode 100644 index 0000000..7ca70ec --- /dev/null +++ b/resources/views/saml/post_response.blade.php @@ -0,0 +1,85 @@ + + + + + Redirecting to {{ $appName }}... + + + +
+
+

Securely connecting to {{ $appName }}

+

You are being authenticated. We are securely transferring your session, please do not close this window.

+ +
+ + @if($relayState) + + @endif + +
+
+ + diff --git a/routes/web.php b/routes/web.php index db004f4..40e8040 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,12 +4,15 @@ use App\Enums\DefaultUserRole; use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum}; -use App\Http\Controllers\ResolveDashboardRouteController; +use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController}; use Illuminate\Support\Facades\Route; use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware}; Route::view('/', 'welcome')->name('home'); +Route::get('saml/metadata', [SamlIdpController::class, 'metadata'])->name('saml.metadata'); +Route::match(['get', 'post'], 'saml/sso', [SamlIdpController::class, 'sso'])->name('saml.sso'); + Route::group(['middleware' => ['auth', 'verified']], function (): void { Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard'); diff --git a/tests/Feature/SamlIdpTest.php b/tests/Feature/SamlIdpTest.php new file mode 100644 index 0000000..9d2c89e --- /dev/null +++ b/tests/Feature/SamlIdpTest.php @@ -0,0 +1,113 @@ +artisan('saml:generate-keys'); + } + + $this->seed(Database\Seeders\DatabaseSeeder::class); + + // Get seeded protocol, provider, and status + $this->protocol = ConnectionProtocol::query()->where('name', 'saml')->firstOrFail(); + $this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail(); + $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + // Create a mock SAML App + $this->samlApp = ConnectedApp::query()->create([ + 'name' => 'Microsoft Office 365', + 'slug' => 'microsoft-office-365', + 'connection_protocol_id' => $this->protocol->id, + 'connection_provider_id' => $this->provider->id, + 'connection_status_id' => $this->status->id, + 'settings' => [ + 'saml' => [ + 'entity_id' => 'urn:federation:MicrosoftOnline', + 'acs_url' => 'https://login.microsoftonline.com/login.srf', + ], + ], + ]); + + // Create a mock User + $this->user = User::factory()->create([ + 'email' => 'testuser@company.com', + 'immutable_id' => 'user-123-immutable', + ]); +}); + +test('saml metadata endpoint returns active public certificate and sso location', function (): void { + $response = $this->get(route('saml.metadata')); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/xml; charset=utf-8'); + + $content = $response->getContent(); + expect($content)->toContain('EntityDescriptor') + ->toContain('IDPSSODescriptor') + ->toContain('SingleSignOnService') + ->toContain(route('saml.sso')) + ->toContain('X509Certificate'); +}); + +test('saml sso endpoint aborts if missing SAMLRequest', function (): void { + $response = $this->get(route('saml.sso')); + + $response->assertStatus(400); +}); + +test('saml sso endpoint redirects unauthenticated users to login page and remembers request', function (): void { + $xml = 'urn:federation:MicrosoftOnline'; + $samlRequest = base64_encode(gzdeflate($xml)); + $relayState = 'https://teams.microsoft.com/'; + + $response = $this->get(route('saml.sso', [ + 'SAMLRequest' => $samlRequest, + 'RelayState' => $relayState, + ])); + + $response->assertRedirect(route('login')); + + // Assert session retains pending details + expect(session('saml_pending_request'))->toBe($samlRequest); + expect(session('saml_pending_relay_state'))->toBe($relayState); +}); + +test('saml sso endpoint signs and redirects authenticated users via POST binding', function (): void { + $xml = 'urn:federation:MicrosoftOnline'; + $samlRequest = base64_encode(gzdeflate($xml)); + $relayState = 'https://teams.microsoft.com/'; + + // Log in the user + $this->actingAs($this->user); + + $response = $this->get(route('saml.sso', [ + 'SAMLRequest' => $samlRequest, + 'RelayState' => $relayState, + ])); + + $response->assertStatus(200); + $response->assertViewIs('saml.post_response'); + $response->assertViewHasAll([ + 'appName' => 'Microsoft Office 365', + 'acsUrl' => 'https://login.microsoftonline.com/login.srf', + 'relayState' => $relayState, + ]); + + $content = $response->getContent(); + expect($content)->toContain('name="SAMLResponse"') + ->toContain('name="RelayState"') + ->toContain('value="'.$relayState.'"'); + + // Verify session was cleared + expect(session('saml_pending_request'))->toBeNull(); +});