SAML SSO — Integration Guide

Parameter Value

Application version

Spring Boot 3.5.6 / Java 25

Library

Spring Security SAML2 (Relying Party)

Standard

SAML 2.0, HTTP POST Binding

1. Overview

1.1. What is SAML SSO

SAML (Security Assertion Markup Language) is an open standard for exchanging authentication data between an Identity Provider (IdP) and a Service Provider (SP). In this project, the OneIoT application acts as the SP, while an external IdP (Keycloak, Azure AD, Okta, ADFS, etc.) performs user authentication.

1.2. How SAML SSO works in this project

The application supports two separate SAML client registrations:

Portal Registration ID SP Entity ID (default)

Support Center (SC)

saml-support-portal

oneiot-sp-sc

Management Console (MC)

saml-management-portal

oneiot-sp-mc

Each portal has its own ACS URL, Entity ID, and post-authentication redirect URL.

1.3. Authentication flow diagram

saml-auth-flow

2. Application configuration

2.1. Prerequisites

To enable SAML SSO:

  1. Set the app.authentication-type variable to saml:

    app:
      authentication-type: saml

    This value determines the authentication type returned by the POST /iot-webservice/iotw/Auth/type endpoint. The frontend uses it to choose between the login/password form and the SSO button.

  2. Set SAML_ENABLED=true — this activates the SamlAuthenticationSuccessHandler bean and the SAML filter chain in SecurityConfig.

2.2. Environment variables

Environment variable YAML path Default value Description

SAML_ENABLED

app.saml.enabled

false

Enables/disables SAML SSO. When false, the SamlAuthenticationSuccessHandler bean is not created and the filter chain is not registered.

SAML_REDIRECT_HOST

app.saml.redirect-url

 — (required)

Base application URL used to construct redirect links and the ACS URL. Example: https://portal.example.com

SAML_IDP_ENTITY_ID

app.saml.idp.entity-id

 — (required)

Identity Provider Entity ID. Example: https://keycloak.example.com/realms/oneiot

SAML_IDP_SSO_URL

app.saml.idp.sso-url

 — (required)

IdP Single Sign-On URL. Example: https://keycloak.example.com/realms/oneiot/protocol/saml

SAML_IDP_CERT

app.saml.idp.certificate-location

classpath:saml/idp.crt

Path to the IdP certificate (X.509 PEM). Used to verify the SAMLResponse signature.

SAML_SC_SP_ENTITY_ID

app.saml.sp.entity-id

oneiot-sp-sc

Service Provider Entity ID for the Support Center portal.

SAML_SP_KEY

app.saml.sp.private-key-location

classpath:saml/sp.key

Path to the SP private key (PKCS#8 PEM). Used to sign the AuthnRequest (if enabled).

SAML_SP_CERT

app.saml.sp.certificate-location

classpath:saml/sp.crt

Path to the SP certificate (X.509 PEM).

SAML_MC_SP_ENTITY_ID

app.saml.mc.sp-entity-id

oneiot-sp-mc

Service Provider Entity ID for the Management Console portal.

SAML_ATTR_USERNAME

app.saml.mapping.username-attribute

username

Name of the SAML attribute containing the username.

SAML_ATTR_EMAIL

app.saml.mapping.email-attribute

email

Name of the SAML attribute containing the user email.

SAML_ATTR_CLIENT_TYPE

app.saml.mapping.client-type-attribute

portal-type

Name of the SAML attribute determining the portal type (sc / mc).

SAML_ATTR_DOMAIN

app.saml.mapping.domain-attribute

domain

Name of the SAML attribute containing the user domain. The value must match the domain name in the database.

SAML_ATTR_USER_GROUP

app.saml.mapping.user-group-attribute

user-group

Name of the SAML attribute containing the user group. The value must match the group name in the database.

SAML_ATTR_EXPIRE_DATE

app.saml.mapping.expire-date-attribute

expire-date

Name of the SAML attribute with the account expiration date. Formats: ISO-8601 (2027-01-01T00:00:00Z) or date (2027-01-01).

2.3. Spring Security SAML2 Relying Party configuration

The application.yaml file defines two relying party registrations under spring.security.saml2.relyingparty.registration:

2.3.1. saml-support-portal (SC)

spring:
  security:
    saml2:
      relyingparty:
        registration:
          saml-support-portal:
            entity-id: ${SAML_SC_SP_ENTITY_ID:oneiot-sp-sc}
            acs:
              location: "${SAML_REDIRECT_HOST}/iot-webservice/login/saml2/sso/{registrationId}"
              binding: post
            singlelogout:
              binding: post
            signing:
              credentials:
                - private-key-location: ${SAML_SP_KEY:classpath:saml/sp.key}
                  certificate-location: ${SAML_SP_CERT:classpath:saml/sp.crt}
            assertingparty:
              entity-id: ${SAML_IDP_ENTITY_ID}
              singlesignon:
                url: ${SAML_IDP_SSO_URL}
                binding: post
                sign-request: false
              verification:
                credentials:
                  - certificate-location: ${SAML_IDP_CERT:classpath:saml/idp.crt}

2.3.2. saml-management-portal (MC)

          saml-management-portal:
            entity-id: ${SAML_MC_SP_ENTITY_ID:oneiot-sp-mc}
            acs:
              location: "{baseUrl}/iot-webservice/login/saml2/sso/{registrationId}"
              binding: post
            singlelogout:
              binding: post
            signing:
              credentials:
                - private-key-location: ${SAML_SP_KEY:classpath:saml/sp.key}
                  certificate-location: ${SAML_SP_CERT:classpath:saml/sp.crt}
            assertingparty:
              entity-id: ${SAML_IDP_ENTITY_ID}
              singlesignon:
                url: ${SAML_IDP_SSO_URL}
                binding: post
              verification:
                credentials:
                  - certificate-location: ${SAML_IDP_CERT:classpath:saml/idp.crt}

Key points:

  • sign-request: false — the AuthnRequest is not signed (simplifies IdP integration since the IdP does not need to verify the request signature).

  • Both registrations use the same SP key pair for signing credentials but have different entity-id values.

  • The SC ACS URL uses ${SAML_REDIRECT_HOST} (hardcoded host), while MC uses {baseUrl} (auto-detected from the incoming request).

2.4. ACS URL format

${SAML_REDIRECT_HOST}/iot-webservice/login/saml2/sso/{registrationId}

Specific values:

  • SC: https://portal.example.com/iot-webservice/login/saml2/sso/saml-support-portal

  • MC: https://portal.example.com/iot-webservice/login/saml2/sso/saml-management-portal

2.5. Post-authentication redirect URLs

Portal URL Configuration

SC

${SAML_REDIRECT_HOST}/support-portal/login/auth-callback

app.saml.success-redirect-url

MC

${SAML_REDIRECT_HOST}/management-portal/login/auth-callback

app.saml.mc.success-redirect-url

The frontend receives the JWT token and sessionHash as query parameters:

https://portal.example.com/support-portal/login/auth-callback?token=eyJhbG...&sessionHash=BASE64...

2.6. SP Metadata URL

The application automatically publishes SP metadata at:

/iot-webservice/saml2/metadata/{registrationId}

Examples:

  • https://portal.example.com/iot-webservice/saml2/metadata/saml-support-portal

  • https://portal.example.com/iot-webservice/saml2/metadata/saml-management-portal

This URL can be used in the IdP for automatic SP configuration.

3. Certificates

All certificates are located in iot-web-app-build/src/main/resources/saml/:

File Purpose Format

sp.key

Service Provider private key

PKCS#8 PEM

sp.crt

Service Provider certificate

X.509 PEM

idp.crt

Identity Provider certificate

X.509 PEM

3.1. SP certificate (sp.crt + sp.key)

Used for:

  • Signing SAML AuthnRequest (if sign-request: true in the configuration)

  • Inclusion in SP metadata in the <KeyDescriptor use="signing"> section

3.2. IdP certificate (idp.crt)

Used for:

  • Verifying the signature of the SAMLResponse received from the IdP

  • Must exactly match the signing certificate configured in the IdP

The file must be in X.509 PEM format with the following structure:

-----BEGIN CERTIFICATE-----
MIICnTCCAYUCBgGE... (base64-encoded certificate data)
...
-----END CERTIFICATE-----
The certificate MUST include the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- header/footer lines. The content between headers must be base64-encoded. If the IdP rotates its signing keys, you MUST update the idp.crt file in the application. A certificate mismatch will cause signature verification failure and authentication denial. See Exporting IdP Signing Certificate for how to obtain the certificate from Keycloak.

3.3. Generating the SP key pair

To generate a new private key and self-signed SP certificate, use OpenSSL:

# Generate RSA 2048-bit private key (PKCS#8 PEM)
openssl genpkey -algorithm RSA -out sp.key -pkeyopt rsa_keygen_bits:2048

# Generate self-signed certificate valid for 10 years
openssl req -new -x509 -key sp.key -out sp.crt -days 3650 -subj "/CN=oneiot-sp"

Or in a single command (generates key and certificate simultaneously):

openssl req -new -x509 -newkey rsa:2048 -nodes \
  -keyout sp.key -out sp.crt \
  -days 3650 -subj "/CN=oneiot-sp"
The private key must be in PKCS#8 format (header -----BEGIN PRIVATE KEY-----), not in traditional RSA format (-----BEGIN RSA PRIVATE KEY-----). Convert if necessary:
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in sp-rsa.key -out sp.key

3.4. Obtaining the IdP certificate

For Keycloak, the IdP certificate can be obtained from:

  1. Realm Settings > Keys — copy the public key of the RS256 entry with usage SIG

  2. SAML Descriptor URL: https://<KEYCLOAK_HOST>/realms/<REALM>/protocol/saml/descriptor — extract the certificate from <ds:X509Certificate>

  3. Admin Console: Realm Settings > Keys > select RSA (sig) > View Certificate

See Exporting IdP Signing Certificate for detailed step-by-step instructions.

4. API Endpoints

4.1. POST /iot-webservice/iotw/Auth/saml/login-url

Returns the URL for redirecting the user to the IdP.

Authentication: not required (public endpoint).

4.1.1. Request

POST /iot-webservice/iotw/Auth/saml/login-url HTTP/1.1
Content-Type: application/json

{
  "clientType": "sc"
}
Field Type Required Description

clientType

string

Yes

Portal type. Allowed values: sc (Support Center), mc (Management Console).

4.1.2. Successful response (200 OK)

{
  "loginUrl": "https://portal.example.com/iot-webservice/saml2/authenticate/saml-support-portal"
}
Field Type Description

loginUrl

string

Full URL for user redirection. Format: ${SAML_REDIRECT_HOST}/iot-webservice/saml2/authenticate/{registrationId}

4.1.3. Error responses (400 Bad Request)

SAML disabled:

{
  "error": "SAML authentication is not enabled"
}

Invalid clientType:

{
  "error": "Invalid clientType. Must be 'sc' or 'mc'"
}

4.1.4. Registration ID resolution logic

clientType registrationId Description

sc

saml-support-portal

Registration for the Support Center portal

mc

saml-management-portal

Registration for the Management Console portal

4.1.5. curl examples

# Get login URL for SC portal
curl -X POST https://portal.example.com/iot-webservice/iotw/Auth/saml/login-url \
  -H "Content-Type: application/json" \
  -d '{"clientType": "sc"}'

# Get login URL for MC portal
curl -X POST https://portal.example.com/iot-webservice/iotw/Auth/saml/login-url \
  -H "Content-Type: application/json" \
  -d '{"clientType": "mc"}'

4.2. POST /iot-webservice/iotw/Auth/type

Auxiliary endpoint returning the current authentication type.

curl -X POST https://portal.example.com/iot-webservice/iotw/Auth/type

Response when SAML is configured:

"saml"

The frontend uses this endpoint to determine whether to show the login/password form or the SSO button.

5. Keycloak setup (test IdP)

Keycloak is used here solely as a test Identity Provider. In production, any SAML 2.0-compliant IdP may be used: Azure AD (Entra ID), Okta, ADFS, OneLogin, Shibboleth, etc.

5.1. Creating a Realm

  1. Open the Keycloak Admin Console (https://<KEYCLOAK_HOST>/admin).

  2. In the left menu, click the current realm and select Create realm.

  3. Enter:

    • Realm name: oneiot

  4. Click Create.

5.2. Creating SAML clients

You need to create two SAML clients — one for each portal.

5.2.1. Support Center (SC) client

  1. Navigate to Clients > Create client.

  2. Fill in:

    • Client type: SAML

    • Client ID: oneiot-sp-sc

  3. Click Next, then Save.

  4. On the Settings tab, configure:

    • Valid Redirect URIs: https://<PORTAL_HOST>.rd.friendly-tech.com/*

    • Master SAML Processing URL: https://<PORTAL_HOST>.rd.friendly-tech.com/iot-webservice/login/saml2/sso/saml-support-portal

    • Name ID Format: username

    • Force Name ID Format: ON

5.2.2. Management Console (MC) client

  1. Navigate to Clients > Create client.

  2. Fill in:

    • Client type: SAML

    • Client ID: oneiot-sp-mc

  3. Click Next, then Save.

  4. On the Settings tab, configure:

    • Valid Redirect URIs: https://<PORTAL_HOST>.rd.friendly-tech.com/*

    • Master SAML Processing URL: https://<PORTAL_HOST>.rd.friendly-tech.com/iot-webservice/login/saml2/sso/saml-management-portal

    • Name ID Format: username

    • Force Name ID Format: ON

5.3. Keys settings

For each client (oneiot-sp-sc and oneiot-sp-mc):

  1. Go to the Keys tab.

  2. Set Client signature required = OFF.

This corresponds to the sign-request: false setting in application.yaml. The application does not sign the AuthnRequest, so the IdP must not require a client signature.

5.4. Exporting IdP Signing Certificate

To configure SAMLResponse signature verification, you must export the IdP’s signing certificate and place it in the application.

  1. Go to Realm Settings > Keys.

  2. Find the RS256 key with provider rsa-generated and usage SIG.

  3. Click the Certificate button in that row to view the X.509 certificate.

  4. Copy the entire certificate content displayed by Keycloak.

  5. Paste it into the file iot-web-app-build/src/main/resources/saml/idp.crt.

The file must be in X.509 PEM format with the following structure:

-----BEGIN CERTIFICATE-----
MIICnTCCAYUCBgGE... (base64-encoded certificate data)
...
-----END CERTIFICATE-----
The certificate MUST include the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- header/footer lines. The certificate content between headers must be base64-encoded (this is what Keycloak displays). If Keycloak only shows the raw base64 without headers, you must manually add the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- wrapper. When Keycloak rotates its signing keys, you MUST update this file with the new certificate, otherwise SAML signature validation will fail.
You can also obtain the certificate from the SAML Descriptor URL: https://<KEYCLOAK_HOST>/realms/<REALM>/protocol/saml/descriptor. The certificate is inside the <ds:X509Certificate> element — copy that value and wrap it with the PEM header/footer lines.

5.5. User Profile (attributes)

  1. Navigate to Realm Settings > User Profile.

  2. Create custom attributes that will be assigned to each user.

The username and email attributes already exist by default. You need to create the following additional attributes:

Attribute Name Display Name Description

domain

${domain}

User domain. The value must exactly match the domain name in the application database.

user-group

${user-group}

User group. The value must exactly match the group name in the application database.

portal-type

${portal-type}

Portal type: sc (Support Center) or mc (Management Console).

For each attribute:

  1. Click Create attribute.

  2. Fill in Attribute name and Display name and SAML name according to the table above.

  3. Optionally set Required field = ON (recommended for domain and user-group).

  4. Click Save.

5.6. Creating users

  1. Navigate to Users > Add user.

  2. Fill in the required fields:

    • Username — the username

    • Email — the user email

    • First name, Last name — first and last name (optional)

  3. Fill in the custom attributes:

    • domain — domain name (e.g., SuperDomain, TenantA)

    • user-group — group name (e.g., admin, operator)

    • portal-type — sc or mc

  4. Click Create.

  5. Go to the Credentials tab and set a password.

The domain and user-group values must exactly match existing records in the application database. If a domain or group is not found in the database, the user will be created with domainId = null or userGroupId = null. The portal-type attribute must contain the value sc or mc.

5.7. Creating Mappers (Protocol Mappers)

To pass custom attributes in the SAML assertion, you need to create mappings for each client.

5.7.1. Configuration for oneiot-sp-sc client

  1. Navigate to Clients > oneiot-sp-sc > Client scopes.

  2. Click on oneiot-sp-sc-dedicated.

  3. Click Add mapper > By configuration > User Attribute.

  4. Create the following mappings:

Mapper Name User Attribute SAML Attribute Name SAML Attribute NameFormat

username

username

username

Basic

email

email

email

Basic

domain

domain

domain

Basic

user-group

user-group

user-group

Basic

portal-type

portal-type

portal-type

Basic

For each mapping:

  1. Fill in the fields according to the table.

  2. Ensure SAML Attribute NameFormat is set to Basic.

  3. Click Save.

5.7.2. Configuration for oneiot-sp-mc client

Repeat the same steps for the oneiot-sp-mc client:

  1. Navigate to Clients > oneiot-sp-mc > Client scopes.

  2. Click on oneiot-sp-mc-dedicated.

  3. Click Add mapper > By configuration > User Attribute.

  4. Create the same 5 mappings from the table above.

After completing all steps, the Keycloak configuration is finished.

6. SAML attribute mapping

6.1. General principle

After successful authentication, the IdP returns a SAMLResponse containing a SAML assertion with user attributes. The SamlAuthService.authenticateFromSamlPrincipal() method extracts these attributes and maps them to internal application fields.

6.2. Attribute mapping table

SAML attribute (default) Environment variable YAML path Used for Required

username

SAML_ATTR_USERNAME

app.saml.mapping.username-attribute

Username in the system. If the attribute is missing, principal.getName() (NameID from assertion) is used.

Recommended

email

SAML_ATTR_EMAIL

app.saml.mapping.email-attribute

User email. If missing — empty string.

Optional

portal-type

SAML_ATTR_CLIENT_TYPE

app.saml.mapping.client-type-attribute

Determines the portal type (SC or MC).

Optional

domain

SAML_ATTR_DOMAIN

app.saml.mapping.domain-attribute

Domain name for user binding. Looked up in the database by name.

Recommended

user-group

SAML_ATTR_USER_GROUP

app.saml.mapping.user-group-attribute

User group name. Looked up in the database by name and client type.

Recommended

expire-date

SAML_ATTR_EXPIRE_DATE

app.saml.mapping.expire-date-attribute

Account expiration date. Formats: 2027-01-01T00:00:00Z or 2027-01-01.

Optional

6.3. ClientType resolution (3-level strategy)

SamlAuthService.extractClientType() determines the portal type using a three-level priority strategy:

clienttype-resolution
Level Source Example Result

1 (highest)

SAML attribute portal-type

portal-type=mc

ClientType.mc

2

Registration ID

saml-management-portal

ClientType.mc

2

Registration ID

saml-support-portal

ClientType.sc

3 (lowest)

Default value

 — 

ClientType.sc

6.4. Domain resolution

  1. The SAML attribute specified in app.saml.mapping.domain-attribute (default: domain) is read.

  2. If the attribute is present, the domain is looked up by name in the database via DomainService.getDomainIdByName().

  3. Special case: if the domain name matches SuperDomain (the SUPER_DOMAIN_NAME constant), domainId = 0 is returned.

  4. If the domain is not found in the database, domainId is set to null.

6.5. User group resolution

  1. The SAML attribute user-group is read.

  2. If the attribute is present, the group is looked up by name and client type via UserGroupService.findGroupIdForSaml(groupName, clientType).

  3. If the group is not found in the database, userGroupId is set to null.

The group is not created automatically. It must exist in the database before the first SAML authentication of the user.

6.6. User creation/update

After all attributes are extracted, UserService.createOrGetExternalUser(userRequest, clientType, zoneId) is called:

  • If a user with the given username already exists — the existing user is returned.

  • If the user does not exist — a new user is created with the extracted attributes.

7. Architecture

7.1. Component architecture diagram

saml-architecture

7.2. SecurityConfig — SAML Filter Chain

The SecurityConfig class defines two SecurityFilterChain beans:

Order Bean Purpose Condition

@Order(1)

samlFilterChain

SAML SSO processing

@ConditionalOnProperty(name = "app.saml.enabled", havingValue = "true")

@Order(2)

filterChain

JWT authentication for REST API

Always active

The SAML filter chain intercepts requests matching:

  • /iot-webservice/saml2/** — sending AuthnRequest to the IdP

  • /iot-webservice/login/saml2/** — receiving SAMLResponse from the IdP

7.2.1. saml2Login configuration

.saml2Login(saml2 -> saml2
    .successHandler(samlSuccessHandler)
    .loginProcessingUrl("/iot-webservice/login/saml2/sso/{registrationId}")
    .authenticationRequestUri("/iot-webservice/saml2/authenticate/{registrationId}")
    .failureHandler(...)
)
Parameter URL Description

loginProcessingUrl

/iot-webservice/login/saml2/sso/{registrationId}

ACS URL where the IdP sends the SAMLResponse (HTTP POST).

authenticationRequestUri

/iot-webservice/saml2/authenticate/{registrationId}

URL that initiates the AuthnRequest to the IdP.

metadataUrl

/iot-webservice/saml2/metadata/{registrationId}

URL for publishing SP metadata (XML).

7.2.2. Public SAML endpoints

All SAML endpoints are registered as public in SecurityConstants.SAML_PUBLIC_ENDPOINTS:

public static final String[] SAML_PUBLIC_ENDPOINTS = {
    "/saml2/authenticate/**",
    "/login/saml2/sso/**",
    "/saml2/metadata/**",
    "/saml2/service-provider-metadata/**"
};

7.3. SamlAuthenticationSuccessHandler

Activated via @ConditionalOnProperty(name = "app.saml.enabled", havingValue = "true").

Sequence of actions on successful SAML authentication:

  1. Extracts registrationId from DefaultSaml2AuthenticatedPrincipal.getRelyingPartyRegistrationId().

  2. Extracts timezone from the HTTP session (saml_timezone attribute).

  3. Calls SamlAuthService.authenticateFromSamlPrincipal() to create the JWT session.

  4. Determines the redirect URL based on registrationId:

    • saml-management-portal — uses app.saml.mc.success-redirect-url

    • All others — uses app.saml.success-redirect-url

  5. Redirects the browser to a URL of the form:

    {redirectBaseUrl}?token={JWT}&sessionHash={BASE64_HASH}

7.4. SamlAuthService

The main SAML authentication service. The authenticateFromSamlPrincipal() method performs:

  1. Attribute extraction from the SAML assertion (username, email, groups, domain, user-group, expire-date).

  2. ClientType resolution using the 3-level strategy.

  3. Domain resolution via DomainService.getDomainIdByName().

  4. Group resolution via UserGroupService.findGroupIdForSaml().

  5. User creation/retrieval via UserService.createOrGetExternalUser().

  6. Session creation via SessionService.createOrUpdateSession().

  7. JWT generation using the HS256 algorithm, with token lifetime determined by jwt.token.expired (default 36,000,000 ms = 10 hours).

7.5. AuthenticationManager

During SAML authentication, SecurityConfig returns a no-op AuthenticationManager that throws ProviderNotFoundException. This is necessary to avoid StackOverflowError when AOP-proxying an empty ProviderManager.

8. Appendix: Full configuration example

# === SAML SSO ===
export SAML_ENABLED=true
export SAML_REDIRECT_HOST=https://portal.example.com

# IdP (Keycloak)
export SAML_IDP_ENTITY_ID=https://keycloak.example.com/realms/oneiot
export SAML_IDP_SSO_URL=https://keycloak.example.com/realms/oneiot/protocol/saml
export SAML_IDP_CERT=classpath:saml/idp.crt

# SP (Support Center)
export SAML_SC_SP_ENTITY_ID=oneiot-sp-sc
export SAML_SP_KEY=classpath:saml/sp.key
export SAML_SP_CERT=classpath:saml/sp.crt

# SP (Management Console)
export SAML_MC_SP_ENTITY_ID=oneiot-sp-mc

# Attribute Mapping (default values, shown for reference)
export SAML_ATTR_USERNAME=username
export SAML_ATTR_EMAIL=email
export SAML_ATTR_CLIENT_TYPE=portal-type
export SAML_ATTR_DOMAIN=domain
export SAML_ATTR_USER_GROUP=user-group
export SAML_ATTR_EXPIRE_DATE=expire-date

# Authentication type
# In application.yaml: app.authentication-type: saml