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:
parent
37f6e00fa2
commit
57f0a4590c
297
.ddev/config.yaml
Normal file
297
.ddev/config.yaml
Normal 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>
|
||||||
|
# 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: <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 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://<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
102
SAML_SSO.md
Normal 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`).
|
||||||
@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,106 +316,64 @@ 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);
|
$assertion->setIdAttribute('ID', true);
|
||||||
|
|
||||||
// Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly
|
$objDSig->addReference(
|
||||||
$assertion->setIdAttribute('ID', true);
|
$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
|
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
|
||||||
$objDSig->addReference(
|
$objKey->loadKey($privateKeyPem);
|
||||||
$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, [
|
$objDSig->sign($objKey, $assertion);
|
||||||
'type' => 'private',
|
$objDSig->add509Cert($certPem);
|
||||||
]);
|
|
||||||
$objKey->loadKey($privateKeyPem, false);
|
|
||||||
|
|
||||||
// Sign the XML
|
// Enforce SAML Schema order compliance: Signature node must precede Subject.
|
||||||
$objDSig->sign($objKey, $assertion);
|
$signatureNode = $assertion->getElementsByTagNameNS(
|
||||||
$objDSig->add509Cert($certPem);
|
|
||||||
|
|
||||||
// Enforce SAML Schema order compliance: Signature node must precede Subject.
|
|
||||||
$signatureNode = $assertion
|
|
||||||
->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(
|
return File::get($path);
|
||||||
"SAML private key not found at [{$path}]. Run 'php artisan saml:generate-keys'.",
|
} 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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
200
tests/Unit/SamlIdpControllerTest.php
Normal file
200
tests/Unit/SamlIdpControllerTest.php
Normal 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>');
|
||||||
|
});
|
||||||
122
tests/Unit/SamlIdpServiceTest.php
Normal file
122
tests/Unit/SamlIdpServiceTest.php
Normal 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
|
||||||
|
});
|
||||||
@ -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/**'],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user