SAML applications

The Maverics Identity Orchestrator can be used as an IDP to protect SAML apps.

ℹ️
To define SAML type apps the samlProvider must be defined.

Configuration options

Name

name is a unique identifier for the app.

Type

type represents the application type. When defining SAML apps, the type should be saml.

Audience

audience is a unique identifier of the application. The audience value is typically a URL, and it should match the Issuer field provided by the SAML service provider.

Consumer Service URLs

consumerServiceURLs defines the list of allowed URLs where SP initiated
authentication requests will be responded to. At least one URL must be defined.

URL

url is the endpoint where the SAML response may be sent. This field is required.

Default

default is a boolean value that defines the default URL where the SAML response will be sent. This field is optional and defaults to false. At most one URL can be defined as the default.

When defined, the default URL will be used for IDP initiated login. The default ACS will also be used when an SP-initiated AuthnRequest does not contain a AssertionConsumerServiceURL attribute.

Logout Service URL

logoutServiceURL is an optional field that defines the URL where SAML logout
responses will be sent. This field must be defined if requiring logout functionality.

IDP Initiated Login

The idpInitiatedLogin key is an optional configuration which when specified will enable the auth provider to perform IDP initiated login to the client.

Login URL

loginURL is the endpoint that the user will visit from their browser to initiate the IDP login flow. This endpoint needs to be unique on the Orchestrator.

Relay State URL

relayStateURL is the endpoint that gets passed to the service provider and is intended to be the landing page for the user after the authentication flow is complete.

Build Relay State Service Extension

buildRelayStateSE can optionally be used to build the RelayState parameter in an IDP-initiated login flow. This extension can be used when the relay state is dynamic and therefore cannot be defined with a static relayStateURL.

ℹ️
Please note that SAML service providers should protect against the Open Redirect attack vector.

Load Attributes Service Extension

loadAttrsSE is an optional service extension used to customize how the loading of attributes is done. This extension is often used to load attributes from proprietary data sources such as enterprise APIs.

Duration

duration is the time in seconds for which the SAML assertions are valid. If a value is not defined, a duration of five minutes (300 seconds) will be used.

Name ID

nameID is an optional field used to define the properties of the NameID element in a SAML response.

Format

format is an optional field used to define the NameID Format that will be used in the SAML assertion. When not defined, a value of 'urn:oasis:names:tc:SAML:1.0:nameid-format:unspecified' will be used. If a NameIDPolicy is defined on the SAML Authentication request, it must match the NameID Format defined on the client. For more details on NameID Format, please see section 2.2, 3.4.1, and 8.3 of the SAML spec.

Attribute Mapping

attrMapping sets the NameID value in the SAML response. When not defined, the user’s subject will be used. In order to load an attribute from the session cache, use the connectorName.attributeValue syntax.

Request Verification

requestVerification defines the public key used to validate the signature of incoming requests. Alternatively, it can be used to disable request signing verification.

Certificate

certificate is the RSA x509 certificate, and will be used to verify the signatures of incoming requests. It may be defined inline or with a secret provider. It must be RSA compatible. Currently this auth provider only supports SHA-256 for request signing and digest algorithm.

Skip Verification

skipVerification is a boolean value and is used when the client does not want to validate the signatures of incoming requests.

Authentication

authentication defines how users are authenticated for this app.

IDPs

idps lists the IDPs which will be used to authenticate the user.

IsAuthenticated Service Extension

isAuthenticatedSE is an optional Service Extension that can be used to override the default behavior that determines if a user is already authenticated. This extension must be used with authenticateSE.

Authenticate Service Extension

authenticateSE is an optional Service Extension used to take control of how authentication will be done. This extension must be used with isAuthenticatedSE.

Authorization

authorization is an optional config which defines access control rules that are required to access the app.

⚠️

If authorization is omitted, it defaults to allowAll: true. All authenticated users will be authorized to access the app.

Additionally, SAML app authorization rules are now part of the SAML app definition. Any connector names referenced within SAML app policies MUST be included as an Attribute Provider.

Allow All

allowAll enables open access to a given app. This option is usually used when fine-grained authorization is not being enforced.

Rules

rules define a list of access control conditions. All rules must evaluate to true in order user to get access to a given app.

And

and defines a list of conditions that must all be true.

Or

or defines a list of conditions where at least one condition must be true.

Operators

The operators defined below can be listed under an or or and rule. All operators expect exactly two values (operands).

In order to load an attribute from the session cache that will be leveraged in policy, use the {{ connectorName.attributeName }} syntax. For example, {{ ldap.memberOf }} could be used to write a policy that was predicated on LDAP group membership.

Additionally, an HTTP request method can be considered during policy evaluation by using the {{ http.request.method }} syntax. The HTTP request method will be returned as an uppercase string, for example, POST. Please note that only the HTTP request method attribute can be used in policy, but support for other HTTP attributes will be added over time.

equals evaluates to true if the two values are equivalent.

notEquals evaluates to true if the two values are not equivalent.

contains evaluates to true if the left-hand operand (the complete string) contains the right-hand operand (a substring).

notContains evaluates to true if the left-hand operand (the complete string) does not contain the right-hand operand (a substring).

IsAuthorized Service Extension

isAuthorizedSE is an optional service extension that can be used to override the default behavior that determines if a user is authorized.

Attribute Providers

The attrProviders is an optional configuration for an identity system or data store from which the SAMLProvider may retrieve additional attributes used in claimsMapping.

Connector

Connector is a reference to the name of the defined connector which will be used as an attribute provider.

usernameMapping

The usernameMapping configuration makes sure the Attribute Provider (i.e. LDAP Connector) has the correct attribute it needs to successfully query for the user’s attributes. It specifies the attribute used to look up user attributes from the defined attribute provider.

Signature

signature defines the certificate and key used when signing SAML responses. By default, both the SAML response element and SAML assertion element will be signed.

This field is optional when defined on a SAML app. When the signature properties are not defined on the app, the certificates attached to the SAML Provider will be used. It may be advantageous to define unique signing certificates per SAML app to help manage the testing overhead of certificate rotations.

To retrieve app-specific metadata, add the appID query param when making a metadata request. For example, https://idp.enterprise.com/idp/saml/metadata.xml?appID=0cb87d2a-76af-4450-8d7c-455b45eec312 where 0cb87d2a-76af-4450-8d7c-455b45eec312 represents the app’s ID (name).

Certificate

certificate the x509 certificate used by SAML service providers to validate the signature of SAML response and assertions.

Private Key

privateKey is the RSA256 private key used to sign SAML assertions.

Disable Signed Response

disableSignedResponse a boolean value to disable the signing of the SAML response element.

Disable Signed Assertion

disableSignedAssertion a boolean value to disable the signing of the SAML assertion element.

Encryption

encryption is an optional configuration used for encrypting the SAML assertion.

Key Encrypt Method

keyEncryptMethod configures the encryption method for encrypting the symmetric key which is used to encrypt the assertion. Currently, we support two values here which are RSA_OAEP and RSA-1_5.

RSA-1_5 is not recommended according to the XML encryption spec due to security risks associated with the algorithm.

Data Encrypt Method

dataEncryptMethod configures the encryption method for encrypting the actual data of the assertion. Valid values are AES128CBC, AES192CBC, and AES256CBC.

Digest Method

digestMethod is the message digest algorithm use to compute a message digest as part of the encryption process. Valid values are SHA256, and SHA512.

Certificate

certificate is the PEM encoded string that can be defined inline or via a secret provider. This certificate is typically retrieved from the Service Provider.

Claims Mapping

claimsMapping defines how to map attributes from the session to a SAML assertion.

Build Claims Service Extensions

buildClaimsSE is an optional Service Extension that can customize which attributes will be added to the SAML 2.0 AttributeStatement.

ℹ️
Only a subset of data types can be used as attribute values. The following builtin data types are supported as attribute values: bool, int, int8, int32, int64, float32, float64, and string.

Examples

Basic SAML App Config Example

apps:
  - name: exampleSAMLApp
    type: saml
    audience: https://app.enterprise.com
    consumerServiceURLs: 
      - url: https://app.enterprise.com/acs
        default: true
      - url: https://app2.enterprise.com/acs
    logoutServiceURL: https://app.enterprise.com/logout
    idpInitiatedLogin:
      loginURL: https://idp.enterprise.com/sso/example-app
      relayStateURL: https://app.enterprise.com/index.html
    duration: 300
    requestVerification:
      certificate: <exampleSPRequestVerificationCert>
    nameID:
      format: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
      attrMapping: azure.email
    authentication:
      idps:
        - azure
    attrProviders:
      - connector: ldap
        usernameMapping: azure.email
    signature:
      certificate: <exampleSPSigningCert>
      privateKey: <exampleSPSigningKey>
    encryption:
      keyEncryptMethod: RSA_OAEP
      dataEncryptMethod: AES256CBC
      digestMethod: SHA256
      certificate: <encryptionCert>
    claimsMapping:
      id: azure.name
      email: azure.email
      lastName: azure.surname
      groups: ldap.groupMembership

SAML App Config Example With Authorization Rules

apps:
  - name: exampleSAMLApp
    type: saml
    audience: https://app.enterprise.com
    consumerServiceURLs: 
      - url: https://app.enterprise.com/acs
        default: true
    requestVerification:
      certificate: <appSigningCertificate>
    authentication:
      idps:
        - azure
    attrProviders:
      - connector: ldap
        usernameMapping: azure.email
    authorization:
      rules:
        - and:
            - contains: [ "{{ ldap.groups }}", "admin" ]
            - contains: ["{{ azure.emailaddress }}", "@example.com"]

SAML App With Service Extensions

apps:
  - name: exampleSAMLApp
    type: saml
    audience: https://app.enterprise.com
    consumerServiceURLs:
      - url: https://app.enterprise.com/acs
        default: true
    duration: 300
    requestVerification:
      certificate: <appSigningCertificate>
    idpInitiatedLogin:
      loginURL: https://idp.enterprise.com/sso/example-app
      buildRelayStateSE:
        funcName: BuildRelayState
        file: /etc/maverics/extensions/auth.go
    loadAttrsSE:
      funcName: LoadAttributes
      file: /etc/maverics/extensions/auth.go
    authentication:
      isAuthenticatedSE:
        funcName: IsAuthenticated
        file: /etc/maverics/extensions/auth.go
      authenticateSE:
        funcName: Authenticate
        file: /etc/maverics/extensions/auth.go
    authorization:
      isAuthorizedSE:
        funcName: IsAuthorized
        file: /etc/maverics/auth.go
    attrProviders:
      - connector: ldap
        usernameMapping: azure.email
    buildClaimsSE:
      funcName: BuildClaims
      file: /etc/maverics/extensions/auth.go

/etc/maverics/extensions/auth.go

package main

import (
	"fmt"
	"net/http"

	"github.com/strata-io/service-extension/orchestrator"
)

func IsAuthenticated(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) bool {
	logger := api.Logger()
	logger.Debug("se", "determining if user is authenticated")

	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return false
	}

	isAzureAuth, err := session.GetString("azure.authenticated")
	if err != nil {
		logger.Error("se", "unable to retrieve attribute 'azure.authenticated'", "error", err)
		return false
	}
	if isAzureAuth == "true" {
		return true
	}

	return false
}

func Authenticate(api orchestrator.Orchestrator, rw http.ResponseWriter, req *http.Request) {
	logger := api.Logger()
	logger.Info("se", "authenticating user")

	azureIDP, err := api.IdentityProvider("azure")
	if err != nil {
		logger.Error(
			"se", "failed to retrieve Azure IDP",
			"error", err.Error(),
		)
		http.Error(
			rw,
			http.StatusText(http.StatusInternalServerError),
			http.StatusInternalServerError,
		)
		return
	}
	azureIDP.Login(rw, req)
}

func LoadAttributes(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) error {
	logger := api.Logger()
	logger.Debug("se", "loading custom attributes from LDAP")
	
	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return err
	}

	mail, err := session.GetString("azure.email")
	if err != nil {
		return fmt.Errorf("failed to find user email required for LDAP query: %w", err)
	}

	ldap, err := api.AttributeProvider("ldap")
	if err != nil {
		return fmt.Errorf("failed to find LDAP attribute provider")
	}

	attrs, err := ldap.Query(mail, []string{"givenname", "sn", "mobile"})
	if err != nil {
		return fmt.Errorf("failed to query LDAP: %w", err)
	}

	for k, v := range attrs {
		logger.Debug(
			"se", "setting LDAP attribute on session",
			"attribute", k,
			"value", v,
		)
		_ = session.SetString(k, v)
	}

	err = session.Save()
	if err != nil {
		return fmt.Errorf("unable to save session state: %w", err)
	}
	return nil
}

func IsAuthorized(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) bool {
	logger := api.Logger()
	logger.Debug("se", "determining if user is authorized")
	
	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return false
	}

	rawGroups, _ := session.GetString("azure.groups")
	groups := strings.Split(rawGroups, ",")
	for _, group := range groups {
		if group == "executives" {
			return true
		}
	}

	return false
}

func BuildClaims(api orchestrator.Orchestrator, _ http.ResponseWriter, _ *http.Request) (map[string]any, error) {
	logger := api.Logger()
	logger.Info("se", "building custom claims")

	session, err := api.Session()
	if err != nil {
		logger.Error("se", "unable to retrieve session", "error", err.Error())
		return nil, err
	}

	name, _ := session.GetString("azure.email")
	firstName, _ := session.GetString("azure.givenname")
	lastName, _ := session.GetString("azure.surname")
	return map[string]any{
		"name":      name,
		"firstName": firstName,
		"lastName":  lastName,
	}, nil
}

func BuildRelayState(api orchestrator.Orchestrator, rw http.ResponseWriter, req *http.Request) string {
	const fallbackRelayState = "https://app.enterprise.com/index.html"

	logger := api.Logger()
	logger.Info("se", "building custom relay state")

	session, err := api.Session()
	if err != nil {
		logger.Error(
			"se", "failed to build RelayState: failed to retrieve session",
			"error", err.Error(),
		)
		return fallbackRelayState
	}

	userID, err := session.GetString("azure.objectidentifier")
	if err != nil {
		logger.Error(
			"se", "failed to build RelayState: failed to retrieve value from session",
			"error", err.Error(),
		)
		return fallbackRelayState
	}

	if userID == "" {
		logger.Error(
			"se", "failed to build RelayState: user ID value not found session",
		)
		return fallbackRelayState
	}

	return fmt.Sprintf("https://app.enterprise.com/user/%s", userID)
}