Feat: Add comprehensive support for SAML SSO integration

- Introduced `ddev` configuration for Laravel-based SAML SSO setup.
- Added detailed technical specifications for SAML 2.0 integration, including endpoints, flows, and cryptographic signing.
- Created extensive unit tests to validate `SamlIdpController` and `SamlIdpService` functionalities.
- Enhanced metadata generation, SAML request parsing, and cryptographic signing of responses.
- Implemented models, services, and tests to standardize IdP interactions with Service Providers.
This commit is contained in:
= 2026-06-01 11:04:16 +00:00
parent 37f6e00fa2
commit 57f0a4590c
12 changed files with 985 additions and 273 deletions

297
.ddev/config.yaml Normal file
View File

@ -0,0 +1,297 @@
name: sentientgeeks-admin-sso
type: laravel
docroot: public
php_version: "8.5"
webserver_type: nginx-fpm
xdebug_enabled: false
additional_hostnames: []
additional_fqdns: []
database:
type: mysql
version: "8.0"
use_dns_when_possible: true
composer_version: "2"
web_environment: []
nodejs_version: "26"
corepack_enable: false
web_extra_exposed_ports:
- name: vite
container_port: 5173
http_port: 5172
https_port: 5173
# Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades.
# webimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0, 8.4
# PostgreSQL versions can be 9-18
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart"
# Note that for most people the commands
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
# as leaving Xdebug enabled all the time is a big performance hit.
# xhgui_http_port: "8143"
# xhgui_https_port: "8142"
# The XHGui ports can be changed from the default 8143 and 8142
# Very rarely used
# host_xhgui_port: "8142"
# Can be used to change the host binding port of the XHGui
# application. Rarely used; only when port conflict and
# bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global]
# Default is "xhgui"
# webserver_type: nginx-fpm, apache-fpm, generic
# timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
# using the $TZ environment variable or the /etc/localtime symlink.
# This is the timezone used in the containers and by PHP;
# it can be set to any valid timezone,
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# For example Europe/Dublin or MST7MDT
# composer_root: <relative_path>
# Relative path to the Composer root directory from the project root. This is
# the directory which contains the composer.json and where all Composer related
# commands are executed.
# composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version)
# - stable
# - preview
# - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "24"
# change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
# additional_hostnames:
# - somename
# - someothername
# would provide http and https URLs for "somename.ddev.site"
# and "someothername.ddev.site".
# additional_fqdns:
# - example.com
# - sub1.example.com
# would provide http and https URLs for "example.com" and "sub1.example.com"
# Please take care with this because it can cause great confusion.
# upload_dirs: "custom/upload/dir"
#
# upload_dirs:
# - custom/upload/dir
# - ../private
#
# would set the destination paths for ddev import-files to <docroot>/custom/upload/dir
# When Mutagen is enabled this path is bind-mounted so that all the files
# in the upload_dirs don't have to be synced into Mutagen.
# disable_upload_dirs_warning: false
# If true, turns off the normal warning that says
# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set"
# ddev_version_constraint: ""
# Example:
# ddev_version_constraint: ">= 1.24.8"
# This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats
# working_dir:
# web: /var/www/html
# db: /home
# would set the default working directory for the web and db services.
# These values specify the destination directory for ddev ssh and the
# directory in which commands passed into ddev exec are run.
# omit_containers: [db, ddev-ssh-agent]
# Currently only these containers are supported. Some containers can also be
# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit
# the "db" container, several standard features of DDEV that access the
# database container will be unusable. In the global configuration it is also
# possible to omit ddev-router, but not here.
# performance_mode: "global"
# DDEV offers performance optimization strategies to improve the filesystem
# performance depending on your host system. Should be configured globally.
#
# If set, will override the global config. Possible values are:
# - "global": uses the value from the global config.
# - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project.
#
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook
# host_https_port: "59002"
# The host port binding for https can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.
# host_webserver_port: "59001"
# The host port binding for the ddev-webserver can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.
# host_db_port: "59002"
# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic
# unless explicitly specified.
# mailpit_http_port: "8025"
# mailpit_https_port: "8026"
# The Mailpit ports can be changed from the default 8025 and 8026
# host_mailpit_port: "8025"
# The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true
# If the host has internet access and the domain configured can
# successfully be looked up, DNS will be used for hostname resolution
# instead of editing /etc/hosts
# Defaults to true
# project_tld: ddev.site
# The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard
# share_default_provider: ngrok
# The default share provider to use for "ddev share"
# Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php
# In this case the user must provide all such settings.
# You can inject environment variables into the web container with:
# web_environment:
# - SOMEENV=somevalue
# - SOMEOTHERENV=someothervalue
# no_project_mount: false
# (Experimental) If true, DDEV will not mount the project into the web container;
# the user is responsible for mounting it manually or via a script.
# This is to enable experimentation with alternate file mounting strategies.
# For advanced users only!
# bind_all_interfaces: false
# If true, host ports will be bound on all network interfaces,
# not the localhost interface only. This means that ports
# will be available on the local network if the host firewall
# allows it.
# default_container_timeout: 120
# The default time that DDEV waits for all containers to become ready can be increased from
# the default 120. This helps in importing huge databases, for example.
#web_extra_exposed_ports:
#- name: nodejs
# container_port: 3000
# http_port: 2999
# https_port: 3000
#- name: something
# container_port: 4000
# https_port: 4000
# http_port: 3999
# Allows a set of extra ports to be exposed via ddev-router
# Fill in all three fields even if you dont intend to use the https_port!
# If you dont add https_port, then it defaults to 0 and ddev-router will fail to start.
#
# The port behavior on the ddev-webserver must be arranged separately, for example
# using web_extra_daemons.
# For example, with a web app on port 3000 inside the container, this config would
# expose that web app on https://<project>.ddev.site:9999 and http://<project>.ddev.site:9998
# web_extra_exposed_ports:
# - name: myapp
# container_port: 3000
# http_port: 9998
# https_port: 9999
#web_extra_daemons:
#- name: "http-1"
# command: "/var/www/html/node_modules/.bin/http-server -p 3000"
# directory: /var/www/html
#- name: "http-2"
# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000"
# directory: /var/www/html
# override_config: false
# By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
# and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and
# hooks:
# post-start: []
# or
# web_environment: []
# or
# additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer"
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define
# for them. Example:
#hooks:

102
SAML_SSO.md Normal file
View File

@ -0,0 +1,102 @@
# SAML 2.0 SSO Integration Diagram & Specifications
This document provides a comprehensive technical reference for the SAML 2.0 Identity Provider (IdP) integration. It
includes sequence diagrams, an endpoint directory, and data flow models detailing request/response payloads, service
responsibilities, and cryptographic boundaries.
---
## End-to-End SSO Authentication Flow
Below is the complete sequence diagram detailing how a user authenticates into **Microsoft Teams / Outlook** via this
Laravel SSO Identity Provider.
```mermaid
sequenceDiagram
autonumber
actor User as User Browser
participant MS as Microsoft Entra ID<br/>(Service Provider)
participant IdP as Laravel SSO Portal<br/>(Identity Provider)
participant DB as Database
%% Step 1-3: SP Redirection
User ->> MS: 1. Access Microsoft Teams/Outlook (e.g. user@company.com)
MS ->> MS: 2. Detect company.com is federated to Laravel SSO Portal
MS ->> User: 3. Redirect with SAMLRequest & RelayState (HTTP-Redirect)
%% Step 4: Access SSO Endpoint
User ->> IdP: 4. Request /saml/sso?SAMLRequest=...&RelayState=...
%% Step 5-6: Auth Check & Interception
alt User is NOT Authenticated
IdP ->> IdP: 5a. Save SAMLRequest & RelayState to Session
IdP ->> User: 5b. Redirect to /login
User ->> IdP: 5c. User inputs credentials (Fortify login)
IdP ->> DB: 5d. Authenticate user
IdP ->> User: 5e. Redirect to /dashboard (ResolveDashboardRouteController)
Note over IdP, User: ResolveDashboardRouteController intercepts session,<br/>detects saml_pending_request, redirects back to /saml/sso
User ->> IdP: 5f. Request /saml/sso (Authenticated)
else User IS Authenticated
Note over User, IdP: Immediately proceed to Step 6
end
%% Step 7: Parse and Retrieve
IdP ->> DB: 6. Match SAML Issuer (SP Entity ID) against ConnectedApps
DB -->> IdP: Return Connected App metadata & SAML configurations
%% Step 8-11: Response Generation
Note over IdP: App\Services\SamlIdpService<br/>1. Build SAML Response XML<br/>2. Retrieve User email & immutable_id<br/>3. Cryptographically sign Assertion node using idp.key
IdP ->> IdP: 7. Base64 encode signed SAML XML
IdP ->> IdP: 8. Clear SAML pending request from session
IdP -->> User: 9. Render post_response.blade.php with hidden auto-submitting form
%% Step 12-14: Microsoft ACS Callback
User ->> MS: 10. POST SAMLResponse & RelayState to Microsoft ACS URL
Note over MS: 1. Validate XML Signature against IdP public certificate (idp.crt)<br/>2. Map SAML NameID claim to user's ImmutableId
MS -->> User: 11. Grant secure access to Microsoft Teams & Outlook!
```
---
## SAML 2.0 Endpoint Directory
This table maps all endpoints involved in the SAML 2.0 integration, including input values, controller and service
responsibilities, and outputs.
| Endpoint | HTTP Method | Request Inputs / Parameters | Internal Service Called | Core Responsibility | Response Output |
|:---------------------|:---------------|:-------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------|
| **`/saml/metadata`** | `GET` | *None* | `App\Services\SamlIdpService@generateMetadata` | Generates the IdP's official SAML 2.0 XML metadata descriptor to share signing certificates and SSO endpoints with external systems. | `200 OK` (XML payload)<br/>`Content-Type: application/xml` |
| **`/saml/sso`** | `GET` / `POST` | `SAMLRequest` (base64/deflated)<br/>`RelayState` (optional redirect URL) | `App\Services\SamlIdpService@parseRequest`<br/>`App\Services\SamlIdpService@findConnectedApp`<br/>`App\Services\SamlIdpService@generateResponse` | - Decodes and parses SAMLRequest.<br/>- Matches Issuer (Entity ID) to an active SAML Connected App.<br/>- Redirects guests to `/login` with session preservation.<br/>- Intercepts authenticated sessions to build and sign SAML XML assertions. | `200 OK` (renders redirect view) or `302 Redirect` to `/login` |
| **`/dashboard`** | `GET` | *Session Cookie* | `App\Helpers\DashboardRoute` | Intercepts successful post-login routing. If the session holds a pending SAML request, redirects the browser back to `/saml/sso`. | `302 Redirect` to `/saml/sso` (if pending) or dashboard routes |
---
## Data Mapping & Cryptographic Flow
Here is the step-by-step overview of how the Laravel Identity Provider translates local user attributes into a
cryptographically signed assertion accepted by Microsoft:
```
[Local Database] [Cryptographic Signing]
User: Private Key: storage/app/saml/idp.key
├── email: user@company.com Public Cert: storage/app/saml/idp.crt
└── immutable_id: user-123-immutable │
│ ▼
│ (Maps to) ┌───────────────────────────┐
▼ │ XMLSecLibs RSA-SHA256 │
┌─────────────────────────────┐ │ Signs <saml:Assertion>
│ SAML Assertion Claims │────────>│ Moves <Signature> node │
│ - NameID: persistent │ │ before <Subject> for │
│ - IDPEmail: user@company │ │ schema validation. │
│ - mail: user@company │ └───────────────────────────┘
│ - upn: user@company │ │
└─────────────────────────────┘ ▼
┌───────────────────────────┐
│ Base64-encoded Payload │
│ SAMLResponse = PHNhb... │
└───────────────────────────┘
```
### Claim & Attribute Mapping Details
Microsoft Entra ID maps the incoming assertion elements exactly to the cloud user directory:
- **`saml:NameID` (persistent format)**: Directly maps to the user's **`ImmutableId`** (configured locally in the admin
panel or auto-generated deterministically as the base64-encoded user ID).
- **`IDPEmail` / `upn` claims**: Map to the user's **`UserPrincipalName`** (the email address used for login inside
Microsoft 365, e.g. `user@company.com`).

View File

@ -67,12 +67,12 @@ public function handle(): int
// Generate CSR and self-signed certificate // Generate CSR and self-signed certificate
$dn = [ $dn = [
'countryName' => 'US', 'countryName' => 'IN',
'stateOrProvinceName' => 'California', 'stateOrProvinceName' => 'Kolkata',
'localityName' => 'San Francisco', 'localityName' => 'Saltlake',
'organizationName' => 'Laravel SSO', 'organizationName' => 'SENTIENTGEEKS SOFTWARE AND CONSULTANCY PRIVATE LIMITED',
'organizationalUnitName' => 'IT Department', 'organizationalUnitName' => 'IT Department',
'commonName' => 'laravel-sso-idp', 'commonName' => 'sentientgeeks.com',
'emailAddress' => 'admin@sso.local', 'emailAddress' => 'admin@sso.local',
]; ];

View File

@ -11,6 +11,10 @@ class ResolveDashboardRouteController extends Controller
{ {
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
/**
* If the user is redirected after SAML SSO Login (Foritfy Response always redirects to dashboard),
* redirect them to the SSO endpoint, So that SAML SSO Login can be completed.
*/
if (session()->has('saml_pending_request')) { if (session()->has('saml_pending_request')) {
return redirect()->route('saml.sso'); return redirect()->route('saml.sso');
} }

View File

@ -4,6 +4,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User;
use App\Services\SamlIdpService; use App\Services\SamlIdpService;
use Illuminate\Http\{Request, Response}; use Illuminate\Http\{Request, Response};
use Illuminate\Support\Facades\{Auth, Log}; use Illuminate\Support\Facades\{Auth, Log};
@ -19,12 +20,9 @@ public function __construct(private readonly SamlIdpService $samlService) {}
*/ */
public function sso(Request $request) public function sso(Request $request)
{ {
// 1. Retrieve SAMLRequest and RelayState (from request parameters or session fallback) // Retrieve SAMLRequest and RelayState (from request parameters or session fallback)
$samlRequest = $samlRequest = $request->input('SAMLRequest') ?? session('saml_pending_request');
$request->input('SAMLRequest') ?? session('saml_pending_request'); $relayState = $request->input('RelayState') ?? session('saml_pending_relay_state');
$relayState =
$request->input('RelayState') ??
session('saml_pending_relay_state');
if (empty($samlRequest)) { if (empty($samlRequest)) {
Log::warning('SAML SSO accessed without a SAMLRequest.'); Log::warning('SAML SSO accessed without a SAMLRequest.');
@ -32,14 +30,14 @@ public function sso(Request $request)
} }
try { try {
// 2. Parse the SAMLRequest XML // Parse the SAMLRequest XML
$parsed = $this->samlService->parseRequest($samlRequest); $parsed = $this->samlService->parseRequest($samlRequest);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('Failed to parse SAMLRequest: '.$e->getMessage()); Log::error('Failed to parse SAMLRequest: '.$e->getMessage());
abort(400, 'Invalid SAMLRequest: '.$e->getMessage()); abort(400, 'Invalid SAMLRequest: '.$e->getMessage());
} }
// 3. Find corresponding Connected App by issuer (SAML Entity ID) // Find corresponding Connected App by issuer (SAML Entity ID)
$connectedApp = $this->samlService->findConnectedApp($parsed['issuer']); $connectedApp = $this->samlService->findConnectedApp($parsed['issuer']);
if (! $connectedApp) { if (! $connectedApp) {
@ -80,7 +78,7 @@ public function sso(Request $request)
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.'); abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
} }
// 4. Authenticate the User // Authenticate the User
if (! Auth::check()) { if (! Auth::check()) {
// Store the SAML details in session to complete flow post-login // Store the SAML details in session to complete flow post-login
session([ session([
@ -91,11 +89,11 @@ public function sso(Request $request)
return redirect()->route('login'); return redirect()->route('login');
} }
// 5. Generate Signed SAML Response // Generate Signed SAML Response
/** @var User $user */
$user = Auth::user(); $user = Auth::user();
try { try {
/** @var \App\Models\User $user */
$samlResponse = $this->samlService->generateResponse( $samlResponse = $this->samlService->generateResponse(
$user, $user,
$connectedApp, $connectedApp,
@ -107,10 +105,10 @@ public function sso(Request $request)
abort(500, 'Cryptographic signing of SAML Response failed.'); abort(500, 'Cryptographic signing of SAML Response failed.');
} }
// 6. Clear pending SAML request from session // Clear pending SAML request from session
session()->forget(['saml_pending_request', 'saml_pending_relay_state']); session()->forget(['saml_pending_request', 'saml_pending_relay_state']);
// 7. Return HTTP-POST Auto-Submission Page // Return HTTP-POST Auto-Submission Page
return view('saml.post_response', [ return view('saml.post_response', [
'appName' => $connectedApp->name, 'appName' => $connectedApp->name,
'acsUrl' => $acsUrl, 'acsUrl' => $acsUrl,

View File

@ -7,13 +7,14 @@
use App\Models\{ConnectedApp, User}; use App\Models\{ConnectedApp, User};
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use DOMException;
use Exception; use Exception;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\{File, Log};
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RobRichards\XMLSecLibs\{XMLSecurityDSig, XMLSecurityKey}; use RobRichards\XMLSecLibs\{XMLSecurityDSig, XMLSecurityKey};
use RuntimeException; use RuntimeException;
final class SamlIdpService class SamlIdpService
{ {
/** /**
* Decode and parse an incoming SAMLRequest (GET/POST). * Decode and parse an incoming SAMLRequest (GET/POST).
@ -29,34 +30,27 @@ public function parseRequest(string $samlRequest): array
throw new Exception('Failed to base64 decode SAMLRequest.'); throw new Exception('Failed to base64 decode SAMLRequest.');
} }
// Try deflated (GET Redirect binding) // Try deflated (GET Redirect binding), fallback to raw XML (POST binding)
$xml = @gzinflate($decoded); $xml = @gzinflate($decoded) ?: $decoded;
if (false === $xml) {
// Fallback to raw decoded XML (POST binding)
$xml = $decoded;
}
$dom = new DOMDocument(); $dom = new DOMDocument();
$dom->preserveWhiteSpace = false; $dom->preserveWhiteSpace = false;
if (! @$dom->loadXML($xml)) {
// Securely handle XML loading without using '@' suppression
libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml);
libxml_clear_errors();
if (! $loaded) {
throw new Exception('Failed to load SAMLRequest XML.'); throw new Exception('Failed to load SAMLRequest XML.');
} }
$issuerNode = $dom $issuerNode = $dom->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')->item(0)
->getElementsByTagNameNS( ?? $dom->getElementsByTagName('Issuer')->item(0);
'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) : '';
$issuer = $issuerNode ? mb_trim($issuerNode->nodeValue) : '';
$requestId = $dom->documentElement->getAttribute('ID'); $requestId = $dom->documentElement->getAttribute('ID');
$acsUrl = $dom->documentElement->getAttribute( $acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL');
'AssertionConsumerServiceURL',
);
return [ return [
'requestId' => $requestId, 'requestId' => $requestId,
@ -78,14 +72,12 @@ public function findConnectedApp(string $issuer): ?ConnectedApp
/** /**
* Generate the XML SAML metadata for this Identity Provider. * Generate the XML SAML metadata for this Identity Provider.
*/ */
public function generateMetadata( public function generateMetadata(string $idpEntityId, string $ssoUrl): string
string $idpEntityId, {
string $ssoUrl,
): string {
$certPem = $this->getPublicCertificate(); $certPem = $this->getPublicCertificate();
$certClean = $this->cleanPem($certPem); $certClean = $this->cleanPem($certPem);
$xml = <<<XML return mb_trim(<<<XML
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata"> <EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> <IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
@ -100,9 +92,29 @@ public function generateMetadata(
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{$ssoUrl}"/> <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{$ssoUrl}"/>
</IDPSSODescriptor> </IDPSSODescriptor>
</EntityDescriptor> </EntityDescriptor>
XML; XML);
}
return $xml; /**
* Load IdP Public Certificate.
*/
private function getPublicCertificate(): string
{
$path = storage_path('app/saml/idp.crt');
try {
return File::get($path);
} catch (Exception $e) {
Log::error("SAML public certificate not found at [$path].", ['message' => $e->getMessage()]);
throw new RuntimeException("SAML public certificate not found at [$path].", code: 500);
}
}
/**
* 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);
} }
/** /**
@ -110,12 +122,8 @@ public function generateMetadata(
* *
* @throws Exception * @throws Exception
*/ */
public function generateResponse( public function generateResponse(User $user, ConnectedApp $app, string $requestId, string $acsUrl): string
User $user, {
ConnectedApp $app,
string $requestId,
string $acsUrl,
): string {
$idpEntityId = route('saml.metadata'); $idpEntityId = route('saml.metadata');
/** /**
* @var array{ * @var array{
@ -131,167 +139,176 @@ public function generateResponse(
$responseId = '_'.Str::uuid()->toString(); $responseId = '_'.Str::uuid()->toString();
$assertionId = '_'.Str::uuid()->toString(); $assertionId = '_'.Str::uuid()->toString();
// Prepare Timestamps
$now = time(); $now = time();
$issueInstant = gmdate("Y-m-d\TH:i:s\Z", $now); $timestamps = [
$notBefore = gmdate("Y-m-d\TH:i:s\Z", $now - 30); // 30 sec skew tolerance 'issue' => gmdate("Y-m-d\TH:i:s\Z", $now),
$expireInstant = gmdate("Y-m-d\TH:i:s\Z", $now + 300); // 5 min validity 'notBefore' => gmdate("Y-m-d\TH:i:s\Z", $now - 30),
'expire' => gmdate("Y-m-d\TH:i:s\Z", $now + 300),
$immutableId = $user->immutable_id ?? base64_encode((string) $user->id); ];
$dom = new DOMDocument('1.0', 'UTF-8'); $dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false; $dom->preserveWhiteSpace = false;
$response = $dom->createElementNS( // 1. Create Base Response
'urn:oasis:names:tc:SAML:2.0:protocol', $response = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:Response');
'samlp:Response',
);
$response->setAttribute('ID', $responseId); $response->setAttribute('ID', $responseId);
$response->setAttribute('InResponseTo', $requestId); $response->setAttribute('InResponseTo', $requestId);
$response->setAttribute('Version', '2.0'); $response->setAttribute('Version', '2.0');
$response->setAttribute('IssueInstant', $issueInstant); $response->setAttribute('IssueInstant', $timestamps['issue']);
$response->setAttribute('Destination', $acsUrl); $response->setAttribute('Destination', $acsUrl);
$issuer = $dom->createElementNS( $response->appendChild($dom->createElementNS(
'urn:oasis:names:tc:SAML:2.0:assertion', 'urn:oasis:names:tc:SAML:2.0:assertion',
'saml:Issuer', 'saml:Issuer',
$idpEntityId, $idpEntityId
); ));
$response->appendChild($issuer);
$status = $dom->createElement('samlp:Status'); $status = $dom->createElement('samlp:Status');
$statusCode = $dom->createElement('samlp:StatusCode'); $statusCode = $dom->createElement('samlp:StatusCode');
$statusCode->setAttribute( $statusCode->setAttribute('Value', 'urn:oasis:names:tc:SAML:2.0:status:Success');
'Value',
'urn:oasis:names:tc:SAML:2.0:status:Success',
);
$status->appendChild($statusCode); $status->appendChild($statusCode);
$response->appendChild($status); $response->appendChild($status);
$assertion = $dom->createElementNS( // 2. Create Assertion
'urn:oasis:names:tc:SAML:2.0:assertion', $assertion = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Assertion');
'saml:Assertion',
);
$assertion->setAttribute('ID', $assertionId); $assertion->setAttribute('ID', $assertionId);
$assertion->setAttribute('Version', '2.0'); $assertion->setAttribute('Version', '2.0');
$assertion->setAttribute('IssueInstant', $issueInstant); $assertion->setAttribute('IssueInstant', $timestamps['issue']);
$assertion->appendChild($dom->createElement('saml:Issuer', $idpEntityId));
$assertionIssuer = $dom->createElement('saml:Issuer', $idpEntityId); // 3. Build Assertion Sub-components
$assertion->appendChild($assertionIssuer); $this->buildSubject($dom, $assertion, $user, $requestId, $acsUrl, $timestamps['expire']);
$this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']);
$this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId);
$this->buildAttributeStatement($dom, $assertion, $user);
// 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,
);
// emailaddress (Required by Microsoft SAML 2.0 specifications)
$this->addAttribute(
$dom,
$attributeStatement,
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
$user->email,
);
$assertion->appendChild($attributeStatement);
$response->appendChild($assertion); $response->appendChild($assertion);
$dom->appendChild($response); $dom->appendChild($response);
// Sign the assertion node // 4. Sign Assertion & Return Base64 payload
$this->signAssertion($dom, $assertion); $this->signAssertion($dom, $assertion);
return base64_encode($dom->saveXML()); return base64_encode($dom->saveXML());
} }
/** /**
* Add a simple attribute to SAML AttributeStatement. * Build the SAML Subject clause.
*
* @throws DOMException
*/ */
private function addAttribute( private function buildSubject(
DOMDocument $dom, DOMDocument $dom,
DOMElement $statement, DOMElement $assertion,
string $name, User $user,
string $value, string $requestId,
string $acsUrl,
string $expireInstant
): void { ): void {
$immutableId = $user->immutable_id ?? base64_encode((string) $user->id);
$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');
$confirmationData = $dom->createElement('saml:SubjectConfirmationData');
$confirmationData->setAttribute('InResponseTo', $requestId);
$confirmationData->setAttribute('NotOnOrAfter', $expireInstant);
$confirmationData->setAttribute('Recipient', $acsUrl);
$subjectConfirmation->appendChild($confirmationData);
$subject->appendChild($subjectConfirmation);
$assertion->appendChild($subject);
}
/**
* Build the SAML Conditions clause.
*
* @throws DOMException
*/
private function buildConditions(
DOMDocument $dom,
DOMElement $assertion,
string $spEntityId,
string $notBefore,
string $expireInstant
): void {
$conditions = $dom->createElement('saml:Conditions');
$conditions->setAttribute('NotBefore', $notBefore);
$conditions->setAttribute('NotOnOrAfter', $expireInstant);
$audienceRestriction = $dom->createElement('saml:AudienceRestriction');
$audienceRestriction->appendChild($dom->createElement('saml:Audience', $spEntityId));
$conditions->appendChild($audienceRestriction);
$assertion->appendChild($conditions);
}
/**
* Build the SAML Authentication Statement clause.
*
* @throws DOMException
*/
private function buildAuthnStatement(
DOMDocument $dom,
DOMElement $assertion,
string $issueInstant,
string $assertionId
): void {
$authnStatement = $dom->createElement('saml:AuthnStatement');
$authnStatement->setAttribute('AuthnInstant', $issueInstant);
$authnStatement->setAttribute('SessionIndex', $assertionId);
$authnContext = $dom->createElement('saml:AuthnContext');
$authnContext->appendChild(
$dom->createElement(
'saml:AuthnContextClassRef',
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
)
);
$authnStatement->appendChild($authnContext);
$assertion->appendChild($authnStatement);
}
/**
* Build the SAML Attribute Statement (Claims mapping).
*/
private function buildAttributeStatement(DOMDocument $dom, DOMElement $assertion, User $user): void
{
$statement = $dom->createElement('saml:AttributeStatement');
$claims = [
'IDPEmail' => $user->email,
'mail' => $user->email,
'displayName' => $user->name,
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn' => $user->email,
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => $user->email,
];
foreach ($claims as $name => $value) {
$this->addAttribute($dom, $statement, $name, (string) $value);
}
$assertion->appendChild($statement);
}
/**
* Add a simple attribute to SAML AttributeStatement.
*
* @throws DOMException
*/
private function addAttribute(DOMDocument $dom, DOMElement $statement, string $name, string $value): void
{
$attribute = $dom->createElement('saml:Attribute'); $attribute = $dom->createElement('saml:Attribute');
$attribute->setAttribute('Name', $name); $attribute->setAttribute('Name', $name);
$attribute->setAttribute( $attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic');
'NameFormat',
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', $val = $dom->createElement('saml:AttributeValue', htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'));
);
$val = $dom->createElement(
'saml:AttributeValue',
htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'),
);
$attribute->appendChild($val); $attribute->appendChild($val);
$statement->appendChild($attribute); $statement->appendChild($attribute);
} }
@ -299,21 +316,16 @@ private function addAttribute(
/** /**
* Cryptographically sign the Assertion node in DOMDocument. * Cryptographically sign the Assertion node in DOMDocument.
*/ */
private function signAssertion( private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void
DOMDocument $dom, {
DOMElement|DOMDocument $assertion, try {
): void {
$privateKeyPem = $this->getPrivateKey(); $privateKeyPem = $this->getPrivateKey();
$certPem = $this->getPublicCertificate(); $certPem = $this->getPublicCertificate();
// XML Security Signature Setup
$objDSig = new XMLSecurityDSig(); $objDSig = new XMLSecurityDSig();
$objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N);
// Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly
$assertion->setIdAttribute('ID', true); $assertion->setIdAttribute('ID', true);
// Add reference to assertion
$objDSig->addReference( $objDSig->addReference(
$assertion, $assertion,
XMLSecurityDSig::SHA256, XMLSecurityDSig::SHA256,
@ -321,84 +333,47 @@ private function signAssertion(
'http://www.w3.org/2000/09/xmldsig#enveloped-signature', 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
'http://www.w3.org/2001/10/xml-exc-c14n#', 'http://www.w3.org/2001/10/xml-exc-c14n#',
], ],
[ ['id_name' => 'ID']
'id_name' => 'ID',
],
); );
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, [ $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
'type' => 'private', $objKey->loadKey($privateKeyPem);
]);
$objKey->loadKey($privateKeyPem, false);
// Sign the XML
$objDSig->sign($objKey, $assertion); $objDSig->sign($objKey, $assertion);
$objDSig->add509Cert($certPem); $objDSig->add509Cert($certPem);
// Enforce SAML Schema order compliance: Signature node must precede Subject. // Enforce SAML Schema order compliance: Signature node must precede Subject.
$signatureNode = $assertion $signatureNode = $assertion->getElementsByTagNameNS(
->getElementsByTagNameNS(
'http://www.w3.org/2000/09/xmldsig#', 'http://www.w3.org/2000/09/xmldsig#',
'Signature', 'Signature'
) )->item(0);
->item(0); $subjectNode = $assertion->getElementsByTagNameNS(
$subjectNode = $assertion
->getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion', 'urn:oasis:names:tc:SAML:2.0:assertion',
'Subject', 'Subject'
) )->item(0);
->item(0);
if ($signatureNode && $subjectNode) { if ($signatureNode && $subjectNode) {
$assertion->insertBefore($signatureNode, $subjectNode); $assertion->insertBefore($signatureNode, $subjectNode);
} }
} catch (Exception $e) {
Log::error('SAML assertion signing failed.', ['message' => $e->getMessage()]);
throw new RuntimeException('SAML assertion signing failed.', code: 500);
}
} }
/** /**
* Load IdP Private Key. * Load IdP Private Key.
*
* @throws RuntimeException
*/ */
private function getPrivateKey(): string private function getPrivateKey(): string
{ {
$path = storage_path('app/saml/idp.key'); $path = storage_path('app/saml/idp.key');
if (! File::exists($path)) { try {
throw new RuntimeException(
"SAML private key not found at [{$path}]. Run 'php artisan saml:generate-keys'.",
);
}
return File::get($path); return File::get($path);
} } catch (Exception $e) {
Log::error("SAML private key not found at [$path].", ['message' => $e->getMessage()]);
/** throw new RuntimeException("SAML private key not found at [$path].", code: 500);
* 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

@ -22,7 +22,10 @@
"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": "*" "ext-openssl": "*",
"ext-zlib": "*",
"ext-dom": "*",
"ext-libxml": "*"
}, },
"require-dev": { "require-dev": {
"captainhook/captainhook": "^5.29", "captainhook/captainhook": "^5.29",
@ -37,8 +40,7 @@
"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": {

View File

@ -3,7 +3,7 @@
]) ])
@if ($status) @if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}> <x-mary-alert {{$attributes->twMerge(['class' => 'alert-soft alert-success'])}}>
{{ $status }} {!! str_replace('\n', '<br>', $status) !!}
</div> </x-mary-alert>
@endif @endif

View File

@ -9,7 +9,8 @@
</div> </div>
</div> </div>
<!-- Session Status --> <!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')"/> <x-auth-session-status :status="session('status')"/>
<x-auth-session-status class="alert-error!" :status="implode('\n', $errors->all())"/>
</div> </div>
<x-shared.card class="w-md p-6 flex gap-6 flex-col"> <x-shared.card class="w-md p-6 flex gap-6 flex-col">
<div class="rounded-full relative bg-blue-100 text-blue-600 size-15 mx-auto p-4 mt-6"> <div class="rounded-full relative bg-blue-100 text-blue-600 size-15 mx-auto p-4 mt-6">

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
uses(\Tests\TestCase::class);
use App\Http\Controllers\SamlIdpController;
use App\Models\{ConnectedApp, ConnectionStatus, User};
use App\Services\SamlIdpService;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Mockery;
beforeEach(function (): void {
$this->mockService = Mockery::mock(SamlIdpService::class);
$this->controller = new SamlIdpController($this->mockService);
});
afterEach(function (): void {
Mockery::close();
});
test('sso throws 400 bad request when SAMLRequest is empty', function (): void {
$request = Request::create('/saml/sso', 'GET');
expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'Missing SAMLRequest parameter.');
});
test('sso throws 400 bad request when SAMLRequest fails to parse', function (): void {
$request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'invalid-base64']);
$this->mockService->shouldReceive('parseRequest')
->once()
->with('invalid-base64')
->andThrow(new Exception('SAML XML parsing error.'));
expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'Invalid SAMLRequest: SAML XML parsing error.');
});
test('sso throws 403 forbidden when Service Provider is unregistered', function (): void {
$request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'valid-request']);
$this->mockService->shouldReceive('parseRequest')
->once()
->with('valid-request')
->andReturn([
'requestId' => '_req_1',
'issuer' => 'urn:unregistered',
'acsUrl' => 'https://acs.url',
]);
$this->mockService->shouldReceive('findConnectedApp')
->once()
->with('urn:unregistered')
->andReturn(null);
expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'The Service Provider [urn:unregistered] is not authorized in this system.');
});
test('sso throws 403 forbidden when Service Provider is registered but disconnected', function (): void {
$request = Request::create('/saml/sso', 'GET', ['SAMLRequest' => 'valid-request']);
$status = new ConnectionStatus();
$status->name = 'disconnected';
$app = new ConnectedApp();
$app->name = 'Test Disconnected App';
$app->setRelation('status', $status);
$this->mockService->shouldReceive('parseRequest')
->once()
->with('valid-request')
->andReturn([
'requestId' => '_req_1',
'issuer' => 'urn:disconnected',
'acsUrl' => 'https://acs.url',
]);
$this->mockService->shouldReceive('findConnectedApp')
->once()
->with('urn:disconnected')
->andReturn($app);
expect(fn () => $this->controller->sso($request))
->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class, 'The Service Provider [Test Disconnected App] is currently deactivated.');
});
test('sso redirects guest user to login page and stores SAML parameters in session', function (): void {
$request = Request::create('/saml/sso', 'GET', [
'SAMLRequest' => 'valid-request',
'RelayState' => 'https://relay.state',
]);
$status = new ConnectionStatus();
$status->name = 'connected';
$app = new ConnectedApp();
$app->name = 'Active SAML App';
$app->setRelation('status', $status);
$app->settings = ['saml' => ['acs_url' => 'https://acs.url']];
$this->mockService->shouldReceive('parseRequest')
->once()
->with('valid-request')
->andReturn([
'requestId' => '_req_1',
'issuer' => 'urn:active',
'acsUrl' => 'https://acs.url',
]);
$this->mockService->shouldReceive('findConnectedApp')
->once()
->with('urn:active')
->andReturn($app);
// Mock guest Auth state
Auth::shouldReceive('check')->once()->andReturn(false);
$response = $this->controller->sso($request);
expect($response->isRedirect(route('login')))->toBeTrue()
->and(session('saml_pending_request'))->toBe('valid-request')
->and(session('saml_pending_relay_state'))->toBe('https://relay.state');
});
test('sso generates SAML Response and auto-submitting POST view for authenticated user', function (): void {
$request = Request::create('/saml/sso', 'GET', [
'SAMLRequest' => 'valid-request',
'RelayState' => 'https://relay.state',
]);
$status = new ConnectionStatus();
$status->name = 'connected';
$app = new ConnectedApp();
$app->name = 'Active SAML App';
$app->setRelation('status', $status);
$app->settings = ['saml' => ['acs_url' => 'https://acs.url']];
$user = new User();
$user->email = 'activeuser@sso.local';
$this->mockService->shouldReceive('parseRequest')
->once()
->with('valid-request')
->andReturn([
'requestId' => '_req_1',
'issuer' => 'urn:active',
'acsUrl' => 'https://acs.url',
]);
$this->mockService->shouldReceive('findConnectedApp')
->once()
->with('urn:active')
->andReturn($app);
// Mock authenticated Auth state
Auth::shouldReceive('check')->once()->andReturn(true);
Auth::shouldReceive('user')->once()->andReturn($user);
$this->mockService->shouldReceive('generateResponse')
->once()
->with($user, $app, '_req_1', 'https://acs.url')
->andReturn('base64-signed-saml-response-xml-payload');
// Populate session to assert it gets cleared
session([
'saml_pending_request' => 'valid-request',
'saml_pending_relay_state' => 'https://relay.state',
]);
$view = $this->controller->sso($request);
expect($view->name())->toBe('saml.post_response')
->and($view->getData()['appName'])->toBe('Active SAML App')
->and($view->getData()['acsUrl'])->toBe('https://acs.url')
->and($view->getData()['samlResponse'])->toBe('base64-signed-saml-response-xml-payload')
->and($view->getData()['relayState'])->toBe('https://relay.state')
->and(session('saml_pending_request'))->toBeNull()
->and(session('saml_pending_relay_state'))->toBeNull();
});
test('metadata returns XML response containing clean endpoints', function (): void {
$this->mockService->shouldReceive('generateMetadata')
->once()
->with(route('saml.metadata'), route('saml.sso'))
->andReturn('<EntityDescriptor></EntityDescriptor>');
$response = $this->controller->metadata();
expect($response->getStatusCode())->toBe(200)
->and($response->headers->get('Content-Type'))->toBe('application/xml; charset=utf-8')
->and($response->getContent())->toBe('<EntityDescriptor></EntityDescriptor>');
});

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
use App\Services\SamlIdpService;
use Exception;
use Illuminate\Support\Facades\File;
beforeEach(function (): void {
$this->service = new SamlIdpService();
// Ensure cryptographic keys exist for the tests
$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);
$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();
$this->connectedApp = ConnectedApp::query()->create([
'name' => 'Test SAML App',
'slug' => 'test-saml-app',
'connection_protocol_id' => $this->protocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
'settings' => [
'saml' => [
'entity_id' => 'urn:federation:TestApp',
'acs_url' => 'https://test-app.com/saml/acs',
],
],
]);
$this->user = User::factory()->create([
'email' => 'unituser@sso.local',
'immutable_id' => 'user-unit-test-123',
]);
});
test('parseRequest correctly decodes and parses deflated GET SAMLRequest', 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="_req_id" Version="2.0" AssertionConsumerServiceURL="https://test-app.com/saml/acs"><saml:Issuer>urn:federation:TestApp</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode(gzdeflate($xml));
$parsed = $this->service->parseRequest($samlRequest);
expect($parsed)->toBeArray()
->and($parsed['requestId'])->toBe('_req_id')
->and($parsed['issuer'])->toBe('urn:federation:TestApp')
->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs');
});
test('parseRequest correctly decodes and parses raw POST SAMLRequest', 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="_req_id_post" Version="2.0" AssertionConsumerServiceURL="https://test-app.com/saml/acs"><saml:Issuer>urn:federation:TestApp</saml:Issuer></samlp:AuthnRequest>';
$samlRequest = base64_encode($xml);
$parsed = $this->service->parseRequest($samlRequest);
expect($parsed)->toBeArray()
->and($parsed['requestId'])->toBe('_req_id_post')
->and($parsed['issuer'])->toBe('urn:federation:TestApp')
->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs');
});
test('parseRequest throws exception on invalid XML or encoding', function (): void {
expect(fn () => $this->service->parseRequest('invalid-base64-string!@#'))
->toThrow(Exception::class);
});
test('findConnectedApp resolves matched active SAML ConnectedApp model', function (): void {
$found = $this->service->findConnectedApp('urn:federation:TestApp');
expect($found)->not->toBeNull()
->and($found->id)->toBe($this->connectedApp->id)
->and($found->name)->toBe('Test SAML App');
});
test('findConnectedApp returns null if SP EntityID is unregistered', function (): void {
$found = $this->service->findConnectedApp('urn:unregistered-entity');
expect($found)->toBeNull();
});
test('generateMetadata returns valid XML schema with certificate', function (): void {
$xml = $this->service->generateMetadata('https://sso.local/metadata', 'https://sso.local/sso');
expect($xml)->toContain('EntityDescriptor')
->toContain('entityID="https://sso.local/metadata"')
->toContain('Location="https://sso.local/sso"')
->toContain('X509Certificate');
});
test('generateResponse builds cryptographically signed SAML Response', function (): void {
$samlResponseBase64 = $this->service->generateResponse(
$this->user,
$this->connectedApp,
'_req_id_123',
'https://test-app.com/saml/acs'
);
expect($samlResponseBase64)->toBeString();
$xml = base64_decode($samlResponseBase64, true);
expect($xml)->toContain('samlp:Response')
->toContain('InResponseTo="_req_id_123"')
->toContain('Destination="https://test-app.com/saml/acs"')
->toContain('saml:Assertion')
->toContain('Signature') // Cryptographically signed
->toContain('user-unit-test-123') // NameID (Immutable ID)
->toContain('unituser@sso.local'); // User email in claims
});

View File

@ -1,10 +1,10 @@
import { import {defineConfig} from 'vite';
defineConfig
} from 'vite';
import laravel from 'laravel-vite-plugin'; import laravel from 'laravel-vite-plugin';
import {bunny} from 'laravel-vite-plugin/fonts'; import {bunny} from 'laravel-vite-plugin/fonts';
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
const isDdev = process.env.DDEV_PRIMARY_URL_WITHOUT_PORT !== undefined;
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
laravel({ laravel({
@ -18,7 +18,18 @@ export default defineConfig({
}), }),
tailwindcss(), tailwindcss(),
], ],
server: { server: isDdev ? {
host: "0.0.0.0",
port: 5173,
strictPort: true,
origin: `${process.env.DDEV_PRIMARY_URL_WITHOUT_PORT}:5173`,
cors: {
origin: /https?:\/\/([A-Za-z0-9\-\.]+)?(\.ddev\.site)(?::\d+)?$/,
},
watch: {
ignored: ['**/storage/framework/views/**'],
},
} : {
cors: true, cors: true,
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],