Feat: add intials SAML SSO compatibility.
- SAML public certificate generation - Authentication using SAML
This commit is contained in:
parent
04c62145b5
commit
4849736c60
115
app/Console/Commands/GenerateSamlKeysCommand.php
Normal file
115
app/Console/Commands/GenerateSamlKeysCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,5 +17,6 @@ public function __construct(
|
|||||||
public int $connectionProtocolId,
|
public int $connectionProtocolId,
|
||||||
public ?string $slug = null,
|
public ?string $slug = null,
|
||||||
public ?int $connectionStatusId = null,
|
public ?int $connectionStatusId = null,
|
||||||
|
public ?array $settings = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,5 +19,6 @@ public function __construct(
|
|||||||
#[MapInputName('connection_status_id')]
|
#[MapInputName('connection_status_id')]
|
||||||
public int $statusId,
|
public int $statusId,
|
||||||
public ?string $slug = null,
|
public ?string $slug = null,
|
||||||
|
public ?array $settings = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,10 @@ class ResolveDashboardRouteController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(Request $request)
|
public function __invoke(Request $request)
|
||||||
{
|
{
|
||||||
|
if (session()->has('saml_pending_request')) {
|
||||||
|
return redirect()->route('saml.sso');
|
||||||
|
}
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$route = DashboardRoute::resolve($user->getRoleNames());
|
$route = DashboardRoute::resolve($user->getRoleNames());
|
||||||
|
|
||||||
|
|||||||
150
app/Http/Controllers/SamlIdpController.php
Normal file
150
app/Http/Controllers/SamlIdpController.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,23 +6,22 @@
|
|||||||
|
|
||||||
use App\Data\ConnectedApp\ConnectedAppData;
|
use App\Data\ConnectedApp\ConnectedAppData;
|
||||||
use App\Services\ConnectedAppService;
|
use App\Services\ConnectedAppService;
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Form;
|
use Livewire\Form;
|
||||||
|
|
||||||
class StoreConnectedAppFrom extends Form
|
class StoreConnectedAppFrom extends Form
|
||||||
{
|
{
|
||||||
#[Validate('required|string|max:255|min:3')]
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
#[Validate('required|numeric|min:1|exists:connection_providers,id')]
|
|
||||||
public int $providerId = 0;
|
public int $providerId = 0;
|
||||||
|
|
||||||
#[Validate('required|numeric|min:1|exists:connection_protocols,id')]
|
|
||||||
public int $protocolId = 0;
|
public int $protocolId = 0;
|
||||||
|
|
||||||
#[Validate('required|numeric|min:1|exists:connection_statuses,id')]
|
|
||||||
public int $statusId = 0;
|
public int $statusId = 0;
|
||||||
|
|
||||||
|
public string $samlEntityId = '';
|
||||||
|
|
||||||
|
public string $samlAcsUrl = '';
|
||||||
|
|
||||||
protected ConnectedAppService $service;
|
protected ConnectedAppService $service;
|
||||||
|
|
||||||
public function boot(ConnectedAppService $service): void
|
public function boot(ConnectedAppService $service): void
|
||||||
@ -30,6 +29,29 @@ public function boot(ConnectedAppService $service): void
|
|||||||
$this->service = $service;
|
$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
|
* This sets the form to edit mode, after fetching the connected app of
|
||||||
* the given id.
|
* the given id.
|
||||||
@ -40,5 +62,7 @@ public function set(ConnectedAppData $data): void
|
|||||||
$this->protocolId = $data->protocolId;
|
$this->protocolId = $data->protocolId;
|
||||||
$this->providerId = $data->providerId;
|
$this->providerId = $data->providerId;
|
||||||
$this->statusId = $data->statusId;
|
$this->statusId = $data->statusId;
|
||||||
|
$this->samlEntityId = $data->settings['saml']['entity_id'] ?? '';
|
||||||
|
$this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,11 +22,24 @@
|
|||||||
'connection_protocol_id',
|
'connection_protocol_id',
|
||||||
'connection_provider_id',
|
'connection_provider_id',
|
||||||
'connection_status_id',
|
'connection_status_id',
|
||||||
|
'settings',
|
||||||
])]
|
])]
|
||||||
class ConnectedApp extends Model
|
class ConnectedApp extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, LogsActivity, SoftDeletes;
|
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>
|
* @return HasOne<ConnectionProtocol, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
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'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
final class User extends Authenticatable
|
final class User extends Authenticatable
|
||||||
{
|
{
|
||||||
|
|||||||
396
app/Services/SamlIdpService.php
Normal file
396
app/Services/SamlIdpService.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,10 +17,12 @@
|
|||||||
"livewire/flux": "^2.13.1",
|
"livewire/flux": "^2.13.1",
|
||||||
"livewire/livewire": "^4.3",
|
"livewire/livewire": "^4.3",
|
||||||
"mallardduck/blade-lucide-icons": "^1.26",
|
"mallardduck/blade-lucide-icons": "^1.26",
|
||||||
|
"robrichards/xmlseclibs": "^3.1",
|
||||||
"robsontenorio/mary": "^2.8",
|
"robsontenorio/mary": "^2.8",
|
||||||
"spatie/laravel-activitylog": "^5.0",
|
"spatie/laravel-activitylog": "^5.0",
|
||||||
"spatie/laravel-data": "^4.22",
|
"spatie/laravel-data": "^4.22",
|
||||||
"spatie/laravel-permission": "^7.4"
|
"spatie/laravel-permission": "^7.4",
|
||||||
|
"ext-openssl": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"captainhook/captainhook": "^5.29",
|
"captainhook/captainhook": "^5.29",
|
||||||
@ -35,7 +37,8 @@
|
|||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.9.3",
|
"nunomaduro/collision": "^8.9.3",
|
||||||
"pestphp/pest": "^4.7",
|
"pestphp/pest": "^4.7",
|
||||||
"pestphp/pest-plugin-laravel": "^4.1"
|
"pestphp/pest-plugin-laravel": "^4.1",
|
||||||
|
"ext-zlib": "*"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
44
composer.lock
generated
44
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f3873c08b246822a39d28768c6205e67",
|
"content-hash": "63deb63c42b1c08194cd70e47ae3f312",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@ -4659,6 +4659,48 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"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",
|
"name": "robsontenorio/mary",
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,7 +3,6 @@ includes:
|
|||||||
- vendor/nesbot/carbon/extension.neon
|
- vendor/nesbot/carbon/extension.neon
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
- app/
|
- app/
|
||||||
- resources/views/
|
- resources/views/
|
||||||
|
|||||||
@ -6,16 +6,19 @@
|
|||||||
use App\Enums\ConnectionStatusEnum;
|
use App\Enums\ConnectionStatusEnum;
|
||||||
use App\Livewire\Forms\StoreConnectedAppFrom;
|
use App\Livewire\Forms\StoreConnectedAppFrom;
|
||||||
use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus};
|
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 Illuminate\Support\Collection;
|
||||||
use Livewire\Attributes\{Computed, Title};
|
use Livewire\Attributes\{Computed, Title};
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Mary\Traits\Toast;
|
use Mary\Traits\Toast;
|
||||||
use Spatie\LaravelData\DataCollection;
|
use Spatie\LaravelData\DataCollection;
|
||||||
|
|
||||||
new
|
new #[Title("Create a service")] class extends Component {
|
||||||
#[Title('Create a service')]
|
|
||||||
class extends Component {
|
|
||||||
use HandlesOperations;
|
use HandlesOperations;
|
||||||
|
|
||||||
public StoreConnectedAppFrom $form;
|
public StoreConnectedAppFrom $form;
|
||||||
@ -32,13 +35,14 @@ public function providers()
|
|||||||
return ConnectionProvider::query()->get();
|
return ConnectionProvider::query()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function protocols(): Collection
|
public function protocols(): Collection
|
||||||
{
|
{
|
||||||
$protocolService = app(ConnectionProtocolService::class);
|
$protocolService = app(ConnectionProtocolService::class);
|
||||||
|
|
||||||
return $protocolService->getAll()->map(function (ConnectionProtocol $protocol) {
|
return $protocolService
|
||||||
|
->getAll()
|
||||||
|
->map(function (ConnectionProtocol $protocol) {
|
||||||
$protocol->name = strtoupper($protocol->name);
|
$protocol->name = strtoupper($protocol->name);
|
||||||
return $protocol;
|
return $protocol;
|
||||||
});
|
});
|
||||||
@ -52,7 +56,9 @@ public function connectionStatuses(): DataCollection
|
|||||||
{
|
{
|
||||||
$statusService = app(ConnectionStatusService::class);
|
$statusService = app(ConnectionStatusService::class);
|
||||||
|
|
||||||
return $statusService->getAll()->map(function (ConnectionStatusData $status) {
|
return $statusService
|
||||||
|
->getAll()
|
||||||
|
->map(function (ConnectionStatusData $status) {
|
||||||
if ($status->name === ConnectionStatusEnum::Disconnected) {
|
if ($status->name === ConnectionStatusEnum::Disconnected) {
|
||||||
$this->form->statusId = $status->id;
|
$this->form->statusId = $status->id;
|
||||||
}
|
}
|
||||||
@ -60,15 +66,37 @@ public function connectionStatuses(): DataCollection
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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
|
public function save(bool $goBack = true): void
|
||||||
{
|
{
|
||||||
$this->form->validate();
|
$this->form->validate();
|
||||||
|
|
||||||
|
$settings = null;
|
||||||
|
if ($this->isSaml()) {
|
||||||
|
$settings = [
|
||||||
|
"saml" => [
|
||||||
|
"entity_id" => $this->form->samlEntityId,
|
||||||
|
"acs_url" => $this->form->samlAcsUrl,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$data = new ConnectAppRequest(
|
$data = new ConnectAppRequest(
|
||||||
name: $this->form->name,
|
name: $this->form->name,
|
||||||
connectionProviderId: $this->form->providerId,
|
connectionProviderId: $this->form->providerId,
|
||||||
connectionProtocolId: $this->form->protocolId,
|
connectionProtocolId: $this->form->protocolId,
|
||||||
slug: str($this->form->name)->slug()->toString(),
|
slug: str($this->form->name)->slug()->toString(),
|
||||||
connectionStatusId: $this->form->statusId
|
connectionStatusId: $this->form->statusId,
|
||||||
|
settings: $settings,
|
||||||
);
|
);
|
||||||
$this->attempt(
|
$this->attempt(
|
||||||
action: function () use ($data, $goBack) {
|
action: function () use ($data, $goBack) {
|
||||||
@ -77,18 +105,15 @@ public function save(bool $goBack = true): void
|
|||||||
if ($goBack) {
|
if ($goBack) {
|
||||||
$this->goBack();
|
$this->goBack();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create() {}
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public function goBack(): void
|
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-input wire:model.live.blur="form.name" required :label="__('Name')"/>
|
||||||
<x-mary-choices
|
<x-mary-choices
|
||||||
required
|
required
|
||||||
wire:model="form.protocolId"
|
wire:model.live="form.protocolId"
|
||||||
:label="__('Protocol')"
|
:label="__('Protocol')"
|
||||||
:single="true"
|
:single="true"
|
||||||
:options="$this->protocols"
|
:options="$this->protocols"
|
||||||
@ -121,6 +146,15 @@ public function goBack(): void
|
|||||||
:options="$this->connectionStatuses->toArray()"
|
:options="$this->connectionStatuses->toArray()"
|
||||||
placeholder="Select"
|
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">
|
<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="goBack" spinner="goBack">Cancel</x-mary-button>
|
||||||
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
|
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
|
||||||
|
|||||||
@ -19,11 +19,10 @@
|
|||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Mary\Traits\Toast;
|
use Mary\Traits\Toast;
|
||||||
use Spatie\LaravelData\DataCollection;
|
use Spatie\LaravelData\DataCollection;
|
||||||
|
/**
|
||||||
new
|
* @property $protocols
|
||||||
#[Lazy]
|
*/
|
||||||
#[Title('Edit a connected app')]
|
new #[Lazy] #[Title("Edit a connected app")] class extends Component {
|
||||||
class extends Component {
|
|
||||||
use HandlesOperations;
|
use HandlesOperations;
|
||||||
|
|
||||||
public StoreConnectedAppFrom $form;
|
public StoreConnectedAppFrom $form;
|
||||||
@ -44,9 +43,9 @@ public function mount(int $id): void
|
|||||||
$this->recordId = $id;
|
$this->recordId = $id;
|
||||||
},
|
},
|
||||||
onError: function () {
|
onError: function () {
|
||||||
$this->redirectRoute('apps.index', navigate: true);
|
$this->redirectRoute("apps.index", navigate: true);
|
||||||
},
|
},
|
||||||
showSuccess: false
|
showSuccess: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,18 +55,29 @@ public function providers()
|
|||||||
return ConnectionProvider::query()->get();
|
return ConnectionProvider::query()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function protocols(): Collection
|
public function protocols(): Collection
|
||||||
{
|
{
|
||||||
$protocolService = app(ConnectionProtocolService::class);
|
$protocolService = app(ConnectionProtocolService::class);
|
||||||
|
|
||||||
return $protocolService->getAll()->map(function (ConnectionProtocol $protocol) {
|
return $protocolService
|
||||||
|
->getAll()
|
||||||
|
->map(function (ConnectionProtocol $protocol) {
|
||||||
$protocol->name = strtoupper($protocol->name);
|
$protocol->name = strtoupper($protocol->name);
|
||||||
return $protocol;
|
return $protocol;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function isSaml(): bool
|
||||||
|
{
|
||||||
|
$protocol = $this->protocols()->firstWhere(
|
||||||
|
"id",
|
||||||
|
(int) $this->form->protocolId,
|
||||||
|
);
|
||||||
|
return $protocol && strtolower($protocol->name) === "saml";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns DataCollection<int, ConnectionStatus>
|
* @returns DataCollection<int, ConnectionStatus>
|
||||||
*/
|
*/
|
||||||
@ -84,15 +94,27 @@ public function save(): void
|
|||||||
$this->attempt(
|
$this->attempt(
|
||||||
action: function () {
|
action: function () {
|
||||||
$this->form->validate();
|
$this->form->validate();
|
||||||
|
|
||||||
|
$settings = null;
|
||||||
|
if ($this->isSaml()) {
|
||||||
|
$settings = [
|
||||||
|
"saml" => [
|
||||||
|
"entity_id" => $this->form->samlEntityId,
|
||||||
|
"acs_url" => $this->form->samlAcsUrl,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$serviceData = new ConnectAppRequest(
|
$serviceData = new ConnectAppRequest(
|
||||||
name: $this->form->name,
|
name: $this->form->name,
|
||||||
connectionProviderId: $this->form->providerId,
|
connectionProviderId: $this->form->providerId,
|
||||||
connectionProtocolId: $this->form->protocolId,
|
connectionProtocolId: $this->form->protocolId,
|
||||||
slug: str($this->form->name)->slug()->toString(),
|
slug: str($this->form->name)->slug()->toString(),
|
||||||
connectionStatusId: $this->form->statusId,
|
connectionStatusId: $this->form->statusId,
|
||||||
|
settings: $settings,
|
||||||
);
|
);
|
||||||
$this->service->update($this->recordId, $serviceData);
|
$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-input wire:model.live.blur="form.name" required :label="__('Name')"/>
|
||||||
<x-mary-choices
|
<x-mary-choices
|
||||||
required
|
required
|
||||||
wire:model="form.protocolId"
|
wire:model.live="form.protocolId"
|
||||||
:label="__('Protocol')"
|
:label="__('Protocol')"
|
||||||
:single="true"
|
:single="true"
|
||||||
:options="$this->protocols"
|
:options="$this->protocols"
|
||||||
@ -129,6 +151,14 @@ public function save(): void
|
|||||||
:options="$this->connectionStatuses->toArray()"
|
:options="$this->connectionStatuses->toArray()"
|
||||||
placeholder="Select"
|
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>
|
</div>
|
||||||
<x-slot:actions class="pr-2">
|
<x-slot:actions class="pr-2">
|
||||||
<x-mary-button :link="route('apps.index')" wire:navigate>
|
<x-mary-button :link="route('apps.index')" wire:navigate>
|
||||||
|
|||||||
85
resources/views/saml/post_response.blade.php
Normal file
85
resources/views/saml/post_response.blade.php
Normal 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>
|
||||||
@ -4,12 +4,15 @@
|
|||||||
|
|
||||||
use App\Enums\DefaultUserRole;
|
use App\Enums\DefaultUserRole;
|
||||||
use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum};
|
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 Illuminate\Support\Facades\Route;
|
||||||
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
||||||
|
|
||||||
Route::view('/', 'welcome')->name('home');
|
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::group(['middleware' => ['auth', 'verified']], function (): void {
|
||||||
Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard');
|
Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard');
|
||||||
|
|
||||||
|
|||||||
113
tests/Feature/SamlIdpTest.php
Normal file
113
tests/Feature/SamlIdpTest.php
Normal 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();
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user