diff --git a/.ddev/config.yaml b/.ddev/config.yaml new file mode 100644 index 0000000..91f4396 --- /dev/null +++ b/.ddev/config.yaml @@ -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: # 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: # 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 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: +# It’s unusual to change this option, and we don’t 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: # mysql, mariadb, postgres +# 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 to be used for http (defaults to global configuration, usually 80) +# router_https_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 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 /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 don’t intend to use the https_port! +# If you don’t 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://.ddev.site:9999 and http://.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: diff --git a/SAML_SSO.md b/SAML_SSO.md new file mode 100644 index 0000000..00e4db8 --- /dev/null +++ b/SAML_SSO.md @@ -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
(Service Provider) + participant IdP as Laravel SSO Portal
(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,
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
1. Build SAML Response XML
2. Retrieve User email & immutable_id
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)
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)
`Content-Type: application/xml` | +| **`/saml/sso`** | `GET` / `POST` | `SAMLRequest` (base64/deflated)
`RelayState` (optional redirect URL) | `App\Services\SamlIdpService@parseRequest`
`App\Services\SamlIdpService@findConnectedApp`
`App\Services\SamlIdpService@generateResponse` | - Decodes and parses SAMLRequest.
- Matches Issuer (Entity ID) to an active SAML Connected App.
- Redirects guests to `/login` with session preservation.
- 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 Claims │────────>│ Moves node │ +│ - NameID: persistent │ │ before 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`). diff --git a/app/Console/Commands/GenerateSamlKeysCommand.php b/app/Console/Commands/GenerateSamlKeysCommand.php index 7583774..db9dc21 100644 --- a/app/Console/Commands/GenerateSamlKeysCommand.php +++ b/app/Console/Commands/GenerateSamlKeysCommand.php @@ -67,12 +67,12 @@ public function handle(): int // Generate CSR and self-signed certificate $dn = [ - 'countryName' => 'US', - 'stateOrProvinceName' => 'California', - 'localityName' => 'San Francisco', - 'organizationName' => 'Laravel SSO', + 'countryName' => 'IN', + 'stateOrProvinceName' => 'Kolkata', + 'localityName' => 'Saltlake', + 'organizationName' => 'SENTIENTGEEKS SOFTWARE AND CONSULTANCY PRIVATE LIMITED', 'organizationalUnitName' => 'IT Department', - 'commonName' => 'laravel-sso-idp', + 'commonName' => 'sentientgeeks.com', 'emailAddress' => 'admin@sso.local', ]; diff --git a/app/Http/Controllers/ResolveDashboardRouteController.php b/app/Http/Controllers/ResolveDashboardRouteController.php index cbdfb94..65f8d64 100644 --- a/app/Http/Controllers/ResolveDashboardRouteController.php +++ b/app/Http/Controllers/ResolveDashboardRouteController.php @@ -11,6 +11,10 @@ class ResolveDashboardRouteController extends Controller { public function __invoke(Request $request) { + /** + * If 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')) { return redirect()->route('saml.sso'); } diff --git a/app/Http/Controllers/SamlIdpController.php b/app/Http/Controllers/SamlIdpController.php index f45a0bb..41258cc 100644 --- a/app/Http/Controllers/SamlIdpController.php +++ b/app/Http/Controllers/SamlIdpController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; +use App\Models\User; use App\Services\SamlIdpService; use Illuminate\Http\{Request, Response}; use Illuminate\Support\Facades\{Auth, Log}; @@ -19,12 +20,9 @@ public function __construct(private readonly SamlIdpService $samlService) {} */ 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'); + // 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.'); @@ -32,14 +30,14 @@ public function sso(Request $request) } try { - // 2. Parse the SAMLRequest XML + // 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) + // Find corresponding Connected App by issuer (SAML Entity ID) $connectedApp = $this->samlService->findConnectedApp($parsed['issuer']); if (! $connectedApp) { @@ -80,7 +78,7 @@ public function sso(Request $request) abort(400, 'Assertion Consumer Service (ACS) URL is not defined.'); } - // 4. Authenticate the User + // Authenticate the User if (! Auth::check()) { // Store the SAML details in session to complete flow post-login session([ @@ -91,11 +89,11 @@ public function sso(Request $request) return redirect()->route('login'); } - // 5. Generate Signed SAML Response + // Generate Signed SAML Response + /** @var User $user */ $user = Auth::user(); try { - /** @var \App\Models\User $user */ $samlResponse = $this->samlService->generateResponse( $user, $connectedApp, @@ -107,10 +105,10 @@ public function sso(Request $request) 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']); - // 7. Return HTTP-POST Auto-Submission Page + // Return HTTP-POST Auto-Submission Page return view('saml.post_response', [ 'appName' => $connectedApp->name, 'acsUrl' => $acsUrl, diff --git a/app/Services/SamlIdpService.php b/app/Services/SamlIdpService.php index d879f41..52fc2cc 100644 --- a/app/Services/SamlIdpService.php +++ b/app/Services/SamlIdpService.php @@ -7,13 +7,14 @@ use App\Models\{ConnectedApp, User}; use DOMDocument; use DOMElement; +use DOMException; use Exception; -use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\{File, Log}; use Illuminate\Support\Str; use RobRichards\XMLSecLibs\{XMLSecurityDSig, XMLSecurityKey}; use RuntimeException; -final class SamlIdpService +class SamlIdpService { /** * 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.'); } - // Try deflated (GET Redirect binding) - $xml = @gzinflate($decoded); - if (false === $xml) { - // Fallback to raw decoded XML (POST binding) - $xml = $decoded; - } + // Try deflated (GET Redirect binding), fallback to raw XML (POST binding) + $xml = @gzinflate($decoded) ?: $decoded; $dom = new DOMDocument(); $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.'); } - $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) : ''; + $issuerNode = $dom->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')->item(0) + ?? $dom->getElementsByTagName('Issuer')->item(0); + $issuer = $issuerNode ? mb_trim($issuerNode->nodeValue) : ''; $requestId = $dom->documentElement->getAttribute('ID'); - $acsUrl = $dom->documentElement->getAttribute( - 'AssertionConsumerServiceURL', - ); + $acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL'); return [ 'requestId' => $requestId, @@ -78,14 +72,12 @@ public function findConnectedApp(string $issuer): ?ConnectedApp /** * Generate the XML SAML metadata for this Identity Provider. */ - public function generateMetadata( - string $idpEntityId, - string $ssoUrl, - ): string { + public function generateMetadata(string $idpEntityId, string $ssoUrl): string + { $certPem = $this->getPublicCertificate(); $certClean = $this->cleanPem($certPem); - $xml = << @@ -100,9 +92,29 @@ public function generateMetadata( - 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 */ - public function generateResponse( - User $user, - ConnectedApp $app, - string $requestId, - string $acsUrl, - ): string { + public function generateResponse(User $user, ConnectedApp $app, string $requestId, string $acsUrl): string + { $idpEntityId = route('saml.metadata'); /** * @var array{ @@ -131,167 +139,176 @@ public function generateResponse( $responseId = '_'.Str::uuid()->toString(); $assertionId = '_'.Str::uuid()->toString(); + // Prepare Timestamps $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); + $timestamps = [ + 'issue' => gmdate("Y-m-d\TH:i:s\Z", $now), + 'notBefore' => gmdate("Y-m-d\TH:i:s\Z", $now - 30), + 'expire' => gmdate("Y-m-d\TH:i:s\Z", $now + 300), + ]; $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; - $response = $dom->createElementNS( - 'urn:oasis:names:tc:SAML:2.0:protocol', - 'samlp:Response', - ); + // 1. Create Base Response + $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('IssueInstant', $timestamps['issue']); $response->setAttribute('Destination', $acsUrl); - $issuer = $dom->createElementNS( + $response->appendChild($dom->createElementNS( 'urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Issuer', - $idpEntityId, - ); - $response->appendChild($issuer); + $idpEntityId + )); $status = $dom->createElement('samlp:Status'); $statusCode = $dom->createElement('samlp:StatusCode'); - $statusCode->setAttribute( - 'Value', - 'urn:oasis:names:tc:SAML:2.0:status:Success', - ); + $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', - ); + // 2. Create Assertion + $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); + $assertion->setAttribute('IssueInstant', $timestamps['issue']); + $assertion->appendChild($dom->createElement('saml:Issuer', $idpEntityId)); - $assertionIssuer = $dom->createElement('saml:Issuer', $idpEntityId); - $assertion->appendChild($assertionIssuer); + // 3. Build Assertion Sub-components + $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); $dom->appendChild($response); - // Sign the assertion node + // 4. Sign Assertion & Return Base64 payload $this->signAssertion($dom, $assertion); 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, - DOMElement $statement, - string $name, - string $value, + DOMElement $assertion, + User $user, + string $requestId, + string $acsUrl, + string $expireInstant ): 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->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->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); } @@ -299,106 +316,64 @@ private function addAttribute( /** * Cryptographically sign the Assertion node in DOMDocument. */ - private function signAssertion( - DOMDocument $dom, - DOMElement|DOMDocument $assertion, - ): void { - $privateKeyPem = $this->getPrivateKey(); - $certPem = $this->getPublicCertificate(); + private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void + { + try { + $privateKeyPem = $this->getPrivateKey(); + $certPem = $this->getPublicCertificate(); - // XML Security Signature Setup - $objDSig = new XMLSecurityDSig(); - $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); + $objDSig = new XMLSecurityDSig(); + $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); + $assertion->setIdAttribute('ID', true); - // Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly - $assertion->setIdAttribute('ID', true); + $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'] + ); - // 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); - $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, [ - 'type' => 'private', - ]); - $objKey->loadKey($privateKeyPem, false); + $objDSig->sign($objKey, $assertion); + $objDSig->add509Cert($certPem); - // Sign the XML - $objDSig->sign($objKey, $assertion); - $objDSig->add509Cert($certPem); - - // Enforce SAML Schema order compliance: Signature node must precede Subject. - $signatureNode = $assertion - ->getElementsByTagNameNS( + // 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( + 'Signature' + )->item(0); + $subjectNode = $assertion->getElementsByTagNameNS( 'urn:oasis:names:tc:SAML:2.0:assertion', - 'Subject', - ) - ->item(0); + 'Subject' + )->item(0); - if ($signatureNode && $subjectNode) { - $assertion->insertBefore($signatureNode, $subjectNode); + if ($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. + * + * @throws RuntimeException */ 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'.", - ); + try { + 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); } - - return File::get($path); - } - - /** - * Load IdP Public Certificate. - */ - private function getPublicCertificate(): string - { - $path = storage_path('app/saml/idp.crt'); - if (! File::exists($path)) { - throw new RuntimeException( - "SAML public certificate not found at [{$path}]. Run 'php artisan saml:generate-keys'.", - ); - } - - return File::get($path); - } - - /** - * Helper to clean up PEM certificate for metadata insertion. - */ - private function cleanPem(string $pem): string - { - return str_replace( - [ - '-----BEGIN CERTIFICATE-----', - '-----END CERTIFICATE-----', - "\r", - "\n", - ' ', - ], - '', - $pem, - ); } } diff --git a/composer.json b/composer.json index bf0e2e4..56cb6ab 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,10 @@ "spatie/laravel-activitylog": "^5.0", "spatie/laravel-data": "^4.22", "spatie/laravel-permission": "^7.4", - "ext-openssl": "*" + "ext-openssl": "*", + "ext-zlib": "*", + "ext-dom": "*", + "ext-libxml": "*" }, "require-dev": { "captainhook/captainhook": "^5.29", @@ -37,8 +40,7 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.9.3", "pestphp/pest": "^4.7", - "pestphp/pest-plugin-laravel": "^4.1", - "ext-zlib": "*" + "pestphp/pest-plugin-laravel": "^4.1" }, "autoload": { "psr-4": { diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php index 98e0011..e6e8cdc 100644 --- a/resources/views/components/auth-session-status.blade.php +++ b/resources/views/components/auth-session-status.blade.php @@ -3,7 +3,7 @@ ]) @if ($status) -
merge(['class' => 'font-medium text-sm text-green-600']) }}> - {{ $status }} -
+ twMerge(['class' => 'alert-soft alert-success'])}}> + {!! str_replace('\n', '
', $status) !!} +
@endif diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index c1f28be..338323e 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -9,7 +9,8 @@ - + +
diff --git a/tests/Unit/SamlIdpControllerTest.php b/tests/Unit/SamlIdpControllerTest.php new file mode 100644 index 0000000..b69bb02 --- /dev/null +++ b/tests/Unit/SamlIdpControllerTest.php @@ -0,0 +1,200 @@ +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(''); + + $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(''); +}); diff --git a/tests/Unit/SamlIdpServiceTest.php b/tests/Unit/SamlIdpServiceTest.php new file mode 100644 index 0000000..7efa010 --- /dev/null +++ b/tests/Unit/SamlIdpServiceTest.php @@ -0,0 +1,122 @@ +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 = 'urn:federation:TestApp'; + $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 = 'urn:federation:TestApp'; + $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 +}); diff --git a/vite.config.js b/vite.config.js index 8b60e14..a8e24b4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,10 @@ -import { - defineConfig -} from 'vite'; +import {defineConfig} from 'vite'; 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"; +const isDdev = process.env.DDEV_PRIMARY_URL_WITHOUT_PORT !== undefined; + export default defineConfig({ plugins: [ laravel({ @@ -18,7 +18,18 @@ export default defineConfig({ }), 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, watch: { ignored: ['**/storage/framework/views/**'],