Skip to content

thoven87/saml-client

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SAMLClient for Swift

A dead simple SAML 2.0 client library for Server Side Swift applications. This library provides an easy way to integrate SAML authentication into your Swift applications.

Features

  • Generate SAML Authentication Requests
  • Generate SAML Logout Requests
  • Parse and validate SAML Responses
  • Extract user attributes from SAML Assertions
  • Support for RelayState parameter
  • Cryptographic support via Swift Crypto for future signature validation
  • HTTP client support via AsyncHTTPClient for metadata fetching
  • Simple and intuitive API
  • Cross-platform support (macOS, Linux, iOS, tvOS, watchOS, visionOS)
  • Fully tested

Installation

Swift Package Manager

Add SAMLClient as a dependency in your Package.swift file:

dependencies: [
    .package(url: "https://github.com/thoven87/saml-client-swift.git", from: "1.0.0")
]

Then add it to your target dependencies:

targets: [
    .target(
        name: "YourTarget",
        dependencies: ["SAMLClient"]),
]

Usage

Basic Setup

First, create a SAML configuration with your Identity Provider (IdP) details:

import SAMLClient

let configuration = SAMLConfiguration(
    entityId: "https://yourapp.com/saml/metadata",
    idpSSOURL: "https://idp.example.com/sso",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    idpSLOURL: "https://idp.example.com/slo"  // Optional: for logout support
)

let client = SAMLClient(configuration: configuration)

Initiating Authentication

To start the SAML authentication flow, generate a redirect URL and redirect the user to it:

// Generate the login URL
let loginURL = try client.getLoginURL()

// Optionally, you can include a relay state to maintain application state
let loginURLWithState = try client.getLoginURL(relayState: "/dashboard")

// Redirect the user to this URL
print("Redirect to: \(loginURL)")

Handling the SAML Response

After the user authenticates with the IdP, they will be redirected back to your Assertion Consumer Service (ACS) URL with a SAML response. Parse and validate this response:

// Get the SAMLResponse parameter from the POST request
let samlResponseBase64 = request.body["SAMLResponse"] // From your web framework

// Parse and validate the response
// Optionally provide clock skew tolerance (in seconds) for time validation
do {
    let response = try client.parseResponse(samlResponseBase64, clockSkew: 60)
    
    // Access the assertion data
    if let assertion = response.assertion {
        print("User ID: \(assertion.nameID ?? "unknown")")
        print("Session Index: \(assertion.sessionIndex ?? "none")")
        
        // Access user attributes
        if let email = assertion.attributes["email"]?.first {
            print("Email: \(email)")
        }
        
        if let firstName = assertion.attributes["firstName"]?.first {
            print("First Name: \(firstName)")
        }
        
        // Create user session, etc.
        // IMPORTANT: Store nameID and sessionIndex for logout
        // session["nameID"] = assertion.nameID
        // session["sessionIndex"] = assertion.sessionIndex
    }
} catch {
    print("Failed to parse SAML response: \(error)")
}

Initiating Logout

To start the SAML logout flow (Single Logout), generate a redirect URL and redirect the user to it:

// Get user information from session
let nameID = session["nameID"] // The NameID from the authentication assertion
let sessionIndex = session["sessionIndex"] // Optional: from the authentication assertion

// Generate the logout URL
let logoutURL = try client.getLogoutURL(
    nameID: nameID,
    sessionIndex: sessionIndex,
    relayState: "/"
)

// Redirect the user to this URL
print("Redirect to: \(logoutURL)")

Note: The IdP Single Logout Service URL (idpSLOURL) must be configured in your SAMLConfiguration for logout to work.

Handling Logout Response

After the IdP processes the logout, they will redirect back to your Single Logout Service (SLO) endpoint. You should clear the user's session:

// In your SLO endpoint handler
session.clear()
// Optionally parse the RelayState to redirect the user
let redirectURL = relayState ?? "/"

Alternative: Parsing Response from Data or ByteBuffer

For frameworks that provide request body as Data or ByteBuffer, you can use the convenience overloads:

// From Data
let bodyData: Data = ...
let response = try client.parseResponse(data: bodyData, clockSkew: 60)

// From ByteBuffer (requires NIOCore)
#if canImport(NIOCore)
import NIOCore
let buffer: ByteBuffer = ...
let response = try client.parseResponse(buffer: buffer, clockSkew: 60)
#endif

Validating Assertion Time Conditions

You can manually validate assertion time conditions using the built-in utility methods:

if let assertion = response.assertion {
    // Validate with default settings (no clock skew)
    let validationResult = assertion.validateTimeConditions()
    
    if validationResult.isValid {
        print("Assertion is valid")
    } else {
        print("Validation failed: \(validationResult.description)")
    }
    
    // Or use the convenience method with clock skew tolerance
    if assertion.isValid(clockSkew: 60) {
        print("Assertion is valid (with 60 second clock skew tolerance)")
    }
}

The validation utility handles:

  • NotBefore condition: Ensures the assertion is not used before it becomes valid
  • NotOnOrAfter condition: Ensures the assertion has not expired
  • Clock skew tolerance: Allows for time differences between servers

Fetching IdP Metadata

You can fetch and parse IdP metadata automatically:

import SAMLClient

// Create configuration from IdP metadata URL
let configuration = try await SAMLConfiguration.fromMetadata(
    entityId: "https://yourapp.com/saml/metadata",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    metadataURL: "https://idp.example.com/metadata"
)

let client = SAMLClient(configuration: configuration)

// Or manually fetch and parse
let httpClient = SAMLHTTPClient()
let (ssoURL, certificate) = try await httpClient.fetchAndParseMetadata(
    from: "https://idp.example.com/metadata"
)
// No need to shutdown - using shared HTTPClient

Cryptographic Operations

Enable signature validation and request signing:

let configuration = SAMLConfiguration(
    entityId: "https://yourapp.com/saml/metadata",
    idpSSOURL: "https://idp.example.com/sso",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    idpCertificate: """
    -----BEGIN CERTIFICATE-----
    MIIDXTCCAkWgAwIBAgIJAKL0UG...
    -----END CERTIFICATE-----
    """,
    privateKey: """
    -----BEGIN PRIVATE KEY-----
    MIIEvQIBADANBgkqhkiG9w0BA...
    -----END PRIVATE KEY-----
    """,
    signRequests: true,
    validateSignatures: true
)

// Requests will be automatically signed
let loginURL = try client.getLoginURL()

// Responses will be automatically validated
let response = try client.parseResponse(samlResponseBase64)

Advanced Configuration

You can configure additional options:

let configuration = SAMLConfiguration(
    entityId: "https://yourapp.com/saml/metadata",
    idpSSOURL: "https://idp.example.com/sso",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    nameIDPolicyFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
)

Configuring NameID Policy Format

You can specify alternative NameID policy formats to match your IdP requirements:

let configuration = SAMLConfiguration(
    entityId: "https://yourapp.com/saml/metadata",
    idpSSOURL: "https://idp.example.com/sso",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    nameIDPolicyFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
)

Common NameID formats include:

  • urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress (default)
  • urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
  • urn:oasis:names:tc:SAML:2.0:nameid-format:transient
  • urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified

Identity Provider Configuration

ADFS

To configure ADFS to work with this library, you should go to the MMC snap-in for ADFS and add a Relying Party Trust with the following properties:

  1. In the Identifiers tab, add a Relying Party Identifier that matches the entityId you'll provide when creating your SAMLConfiguration.
  2. In the Endpoints tab, add the URL that will process SAML responses (your assertionConsumerServiceURL) to the list, using POST for the Binding value.

To obtain the metadata provider XML, load this URL in your browser:

https://myserver.domain.com/FederationMetadata/2007-06/FederationMetadata.xml

Then use it to configure your client:

let configuration = try await SAMLConfiguration.fromMetadata(
    entityId: "https://yourapp.com/saml/metadata",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    metadataURL: "https://myserver.domain.com/FederationMetadata/2007-06/FederationMetadata.xml"
)

let client = SAMLClient(configuration: configuration)

Okta

To configure Okta to work with this library, create an SAML 2.0 application with the following settings:

  1. The Single sign on URL should be the URL that processes SAML responses (your assertionConsumerServiceURL, e.g., https://yourapp.com/saml/acs).
  2. The Audience URI should be a value that matches the entityId you'll specify when creating your SAMLConfiguration.

You can then fetch the Okta metadata URL from your Okta application settings and use it to configure your client:

let configuration = try await SAMLConfiguration.fromMetadata(
    entityId: "https://yourapp.com/saml/metadata",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    metadataURL: "https://your-okta-domain.okta.com/app/your-app-id/sso/saml/metadata"
)

let client = SAMLClient(configuration: configuration)

Encryption and Request Signing

Generating Keys

To generate the public/private keys for encryption and request signing, use the following OpenSSL commands:

# Generate certificate and private key
openssl req -new -x509 -days 365 -nodes -sha256 -out saml-public-key.crt -keyout saml-private-key.pem

# Convert to PKCS8 format (PEM)
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in saml-private-key.pem -out saml-private-key.key

# Convert to DER format (for some IdPs that require .pk8 format)
openssl pkcs8 -topk8 -nocrypt -inform PEM -in saml-private-key.key -outform DER -out saml-private-key.pk8

Using Keys for Signing and Encryption

To add the keys to your SAML client configuration (needed only if you have encrypted assertions or if you want to sign requests):

// Read the certificate and private key from files
let publicKey = try String(contentsOfFile: "saml-public-key.crt")
let privateKey = try String(contentsOfFile: "saml-private-key.key")

let configuration = SAMLConfiguration(
    entityId: "https://yourapp.com/saml/metadata",
    idpSSOURL: "https://idp.example.com/sso",
    assertionConsumerServiceURL: "https://yourapp.com/saml/acs",
    privateKey: privateKey,
    signRequests: true,
    validateSignatures: true
)

let client = SAMLClient(configuration: configuration)

Dependencies: This library includes:

  • Swift Crypto 4.0+: For cryptographic operations including signature validation
  • AsyncHTTPClient 1.29+: For HTTP operations such as fetching IdP metadata

Cryptographic Operations: The library now includes SAMLCrypto module for:

  • XML signature verification using RSA
  • Request signing with private keys
  • Certificate parsing and validation

HTTP Operations: The library includes SAMLHTTPClient for:

  • Fetching IdP metadata from URLs
  • Parsing metadata to extract SSO URLs and certificates
  • Creating configurations directly from metadata URLs

Using with Vapor

Single Identity Provider

If you're using Vapor with a single identity provider, you can easily integrate SAMLClient into your routes:

import Vapor
import SAMLClient

func routes(_ app: Application) throws {
    let config = SAMLConfiguration(
        entityId: "https://yourapp.com/saml/metadata",
        idpSSOURL: "https://idp.example.com/sso",
        assertionConsumerServiceURL: "https://yourapp.com/saml/acs"
    )
    let samlClient = SAMLClient(configuration: config)
    
    // Login route - initiate SAML authentication
    app.get("login") { req -> Response in
        let loginURL = try samlClient.getLoginURL()
        return req.redirect(to: loginURL.absoluteString)
    }
    
    // ACS route - handle SAML response
    app.post("saml", "acs") { req -> Response in
        guard let samlResponse = try? req.content.get(String.self, at: "SAMLResponse") else {
            throw Abort(.badRequest, reason: "Missing SAMLResponse")
        }
        
        let response = try samlClient.parseResponse(samlResponse)
        
        if let assertion = response.assertion,
           let email = assertion.attributes["email"]?.first {
            // Create user session
            req.session.data["user_email"] = email
            return req.redirect(to: "/dashboard")
        }
        
        throw Abort(.unauthorized)
    }
}

API Reference

SAMLConfiguration

Configuration object for the SAML client.

Properties:

  • entityId: Your application's entity ID (Service Provider ID)
  • idpSSOURL: The Identity Provider's SSO URL
  • idpSLOURL: Optional Identity Provider's Single Logout Service URL (required for logout functionality)
  • assertionConsumerServiceURL: Your application's ACS URL (where IdP sends the response)
  • idpCertificate: Optional certificate for signature validation (reserved for future implementation)
  • privateKey: Optional private key for signing requests (reserved for future implementation)
  • signRequests: Whether to sign authentication requests (reserved for future implementation, default: false)
  • validateSignatures: Whether to validate response signatures (reserved for future implementation, default: true)
  • nameIDPolicyFormat: The NameID policy format to use in authentication requests (default: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)

SAMLClient

Main client for handling SAML authentication.

Methods:

  • getLoginURL(relayState:): Generate a redirect URL for authentication
  • getLogoutURL(nameID:sessionIndex:relayState:): Generate a redirect URL for logout
  • parseResponse(_:clockSkew:): Parse and validate a SAML response from base64 string with optional clock skew tolerance
  • parseResponse(data:clockSkew:): Parse and validate a SAML response from Data with optional clock skew tolerance
  • parseResponse(buffer:clockSkew:): Parse and validate a SAML response from ByteBuffer (requires NIOCore) with optional clock skew tolerance
  • generateMetadata(singleLogoutServiceURL:validUntil:): Generate Service Provider metadata XML

SAMLResponse

Represents a parsed SAML response.

Properties:

  • id: The response ID
  • status: The response status (e.g., "Success")
  • issuer: The IdP issuer
  • assertion: The SAML assertion containing user data
  • xml: The raw XML response

SAMLAssertion

Represents a SAML assertion.

Properties:

  • id: The assertion ID
  • nameID: The user's NameID (typically email or username)
  • nameIDFormat: The format of the NameID
  • attributes: Dictionary of user attributes
  • sessionIndex: The session index for logout
  • notBefore: The assertion validity start time
  • notOnOrAfter: The assertion validity end time

Methods:

  • validateTimeConditions(currentDate:clockSkew:): Validate the assertion's time conditions, returns AssertionValidationResult
  • isValid(currentDate:clockSkew:): Check if the assertion is currently valid, returns Bool

AssertionValidationResult

Result of assertion time validation.

Cases:

  • .valid: The assertion is valid
  • .notYetValid(notBefore:currentDate:): The assertion is not yet valid
  • .expired(notOnOrAfter:currentDate:): The assertion has expired

Properties:

  • isValid: Boolean indicating if the validation passed
  • description: Human-readable description of the validation result

SAMLCrypto

Cryptographic utilities for SAML operations.

Methods:

  • verifySignature(xml:certificate:): Verify an XML signature using RSA
  • signXML(xml:privateKey:): Sign XML with RSA private key

SAMLHTTPClient

HTTP client for SAML operations.

Methods:

  • fetchMetadata(from:): Fetch IdP metadata from a URL
  • parseIdPMetadata(_:): Parse IdP metadata and extract SSO URL and certificate
  • fetchAndParseMetadata(from:): Fetch and parse IdP metadata in one operation

Note: Uses HTTPClient.shared - no manual shutdown required

Static Methods:

  • SAMLConfiguration.fromMetadata(entityId:assertionConsumerServiceURL:metadataURL:httpClient:): Create configuration from metadata URL

Error Handling

The library throws SAMLError for various error conditions:

  • encodingError: Failed to encode data
  • decodingError: Failed to decode data
  • compressionError: Failed to compress data
  • invalidURL: Invalid URL construction
  • invalidResponse: Invalid SAML response
  • signatureValidationFailed: Signature validation failed
  • configurationError: Configuration error

Requirements

  • Swift 6.0+
  • macOS 15+, iOS 16+, tvOS 16+, watchOS 9+, visionOS 1+, or Linux

Dependencies

  • Swift Crypto 4.0+: Apple's cryptographic library for signature operations
  • AsyncHTTPClient 1.29+: Swift Server's async HTTP client for HTTP operations

License

This project is licensed under the MIT License - see the LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Examples

Check out the Examples directory for complete working examples:

  • HummingbirdExample.swift: Complete integration with Hummingbird V2 framework

Acknowledgments

Inspired by justinbleach/saml-client

About

A dead simple SAML client based on Open SAML

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages