Feat: add intials SAML SSO compatibility.

- SAML public certificate generation
- Authentication using SAML
This commit is contained in:
= 2026-05-27 05:49:36 +00:00
parent 04c62145b5
commit 4849736c60
19 changed files with 1121 additions and 48 deletions

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class GenerateSamlKeysCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'saml:generate-keys {--force : Overwrite existing keys}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate self-signed SAML private key and public certificate for the IdP';
/**
* Execute the console command.
*/
public function handle(): int
{
$directory = storage_path('app/saml');
$keyPath = "{$directory}/idp.key";
$certPath = "{$directory}/idp.crt";
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
if (File::exists($keyPath) && File::exists($certPath) && ! $this->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;
}
}

View File

@ -17,5 +17,6 @@ public function __construct(
public int $connectionProtocolId,
public ?string $slug = null,
public ?int $connectionStatusId = null,
public ?array $settings = null,
) {}
}

View File

@ -19,5 +19,6 @@ public function __construct(
#[MapInputName('connection_status_id')]
public int $statusId,
public ?string $slug = null,
public ?array $settings = null,
) {}
}

View File

@ -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());

View File

@ -0,0 +1,150 @@
<?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',
]);
}
}

View File

@ -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<string, string>
*/
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'] ?? '';
}
}

View File

@ -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<string, string>
*/
protected function casts(): array
{
return [
'settings' => 'array',
];
}
/**
* @return HasOne<ConnectionProtocol, $this>
*/

View File

@ -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
{

View File

@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\{ConnectedApp, User};
use DOMDocument;
use DOMElement;
use Exception;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RobRichards\XMLSecLibs\{XMLSecurityDSig, XMLSecurityKey};
use RuntimeException;
final class SamlIdpService
{
/**
* Decode and parse an incoming SAMLRequest (GET/POST).
*
* @return array{requestId: string, issuer: string, acsUrl: string}
*
* @throws Exception
*/
public function parseRequest(string $samlRequest): array
{
$decoded = base64_decode($samlRequest, true);
if (false === $decoded) {
throw new Exception('Failed to base64 decode SAMLRequest.');
}
// Try deflated (GET Redirect binding)
$xml = @gzinflate($decoded);
if (false === $xml) {
// Fallback to raw decoded XML (POST binding)
$xml = $decoded;
}
$dom = new DOMDocument();
$dom->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 = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{$certClean}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{$ssoUrl}"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{$ssoUrl}"/>
</IDPSSODescriptor>
</EntityDescriptor>
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,
);
}
}

View File

@ -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": {

44
composer.lock generated
View File

@ -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",

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('connected_apps', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -3,7 +3,6 @@ includes:
- vendor/nesbot/carbon/extension.neon
parameters:
paths:
- app/
- resources/views/

View File

@ -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
<x-mary-input wire:model.live.blur="form.name" required :label="__('Name')"/>
<x-mary-choices
required
wire:model="form.protocolId"
wire:model.live="form.protocolId"
:label="__('Protocol')"
:single="true"
:options="$this->protocols"
@ -121,6 +146,15 @@ public function goBack(): void
:options="$this->connectionStatuses->toArray()"
placeholder="Select"
/>
@if($this->isSaml)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-700/50 pt-4 mt-2">
<h3 class="font-semibold text-sm text-sky-400 md:col-span-2">SAML Configuration</h3>
<x-mary-input wire:model="form.samlEntityId" required :label="__('SAML Entity ID (Audience)')" placeholder="e.g. urn:federation:MicrosoftOnline" />
<x-mary-input wire:model="form.samlAcsUrl" required :label="__('SAML ACS URL (Callback)')" placeholder="e.g. https://login.microsoftonline.com/login.srf" />
</div>
@endif
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>

View File

@ -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
<x-mary-input wire:model.live.blur="form.name" required :label="__('Name')"/>
<x-mary-choices
required
wire:model="form.protocolId"
wire:model.live="form.protocolId"
:label="__('Protocol')"
:single="true"
:options="$this->protocols"
@ -129,6 +151,14 @@ public function save(): void
:options="$this->connectionStatuses->toArray()"
placeholder="Select"
/>
@if($this->isSaml)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-700/50 pt-4 mt-2">
<h3 class="font-semibold text-sm text-sky-400 md:col-span-2">SAML Configuration</h3>
<x-mary-input wire:model="form.samlEntityId" required :label="__('SAML Entity ID (Audience)')" placeholder="e.g. urn:federation:MicrosoftOnline" />
<x-mary-input wire:model="form.samlAcsUrl" required :label="__('SAML ACS URL (Callback)')" placeholder="e.g. https://login.microsoftonline.com/login.srf" />
</div>
@endif
</div>
<x-slot:actions class="pr-2">
<x-mary-button :link="route('apps.index')" wire:navigate>

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Redirecting to {{ $appName }}...</title>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #0f172a;
color: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.container {
text-align: center;
max-width: 480px;
padding: 2.5rem;
background-color: #1e293b;
border-radius: 1rem;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
border: 1px solid #334155;
}
h2 {
margin-top: 0;
font-size: 1.5rem;
font-weight: 600;
color: #38bdf8;
}
p {
color: #94a3b8;
font-size: 0.95rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.spinner {
display: inline-block;
width: 2.5rem;
height: 2.5rem;
border: 3px solid rgba(56, 189, 248, 0.2);
border-radius: 50%;
border-top-color: #38bdf8;
animation: spin 0.8s linear infinite;
margin-bottom: 1.5rem;
}
button {
background-color: #38bdf8;
color: #0f172a;
border: none;
padding: 0.75rem 1.5rem;
font-size: 0.95rem;
font-weight: 600;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #7dd3fc;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body onload="document.forms[0].submit()">
<div class="container">
<div class="spinner"></div>
<h2>Securely connecting to {{ $appName }}</h2>
<p>You are being authenticated. We are securely transferring your session, please do not close this window.</p>
<form method="POST" action="{{ $acsUrl }}">
<input type="hidden" name="SAMLResponse" value="{{ $samlResponse }}" />
@if($relayState)
<input type="hidden" name="RelayState" value="{{ $relayState }}" />
@endif
<noscript>
<p>Javascript is disabled on your browser. Please click the button below to continue.</p>
<button type="submit">Click here to continue</button>
</noscript>
</form>
</div>
</body>
</html>

View File

@ -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');

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
use Illuminate\Support\Facades\File;
beforeEach(function (): void {
// Generate testing keys if not already present
$directory = storage_path('app/saml');
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
if (! File::exists("{$directory}/idp.key") || ! File::exists("{$directory}/idp.crt")) {
$this->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 = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://login.microsoftonline.com/login.srf" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$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 = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_mock_request_id" Version="2.0" IssueInstant="2026-05-26T12:00:00Z" AssertionConsumerServiceURL="https://login.microsoftonline.com/login.srf" Destination="http://localhost/saml/sso"><saml:Issuer>urn:federation:MicrosoftOnline</saml:Issuer></samlp:AuthnRequest>';
$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();
});