Public/Entra/Application/Get-MgApplicationSAML.ps1

<#
    .SYNOPSIS
    Retrieves all Entra ID applications configured for SAML SSO.
 
    .DESCRIPTION
    This function returns a list of all Entra ID applications configured for SAML Single Sign-On
    along with their SAML-related properties, including the PreferredTokenSigningKeyEndDateTime
    and its validity status.
 
    .PARAMETER ObjectID
    (Optional) Retrieves the SAML configuration for a specific application by its ObjectID.
 
    .PARAMETER DisplayName
    (Optional) Retrieves the SAML configuration for a specific application by its DisplayName.
 
    .PARAMETER ExportToExcel
    (Optional) If specified, exports the results to an Excel file in the user's profile directory.
 
    .PARAMETER ForceNewToken
    (Optional) Forces the function to disconnect and reconnect to Microsoft Graph to obtain a new access token.
 
    .PARAMETER RunFromAzureAutomation
    (Optional) If specified, uses managed identity authentication instead of interactive authentication.
    This is useful when running the script in Azure environments like Azure Functions, Logic Apps, or VMs with managed identity enabled.
    When this parameter is used, ExpirationThresholdDays, NotificationRecipient and NotificationSender are required.
 
    PowerShell modules used in Azure Automation must be a MAXIMUM of version 2.25.0 when using PowerShell < 7.4.0, because starting from version 2.26.0, PowerShell 7.4.0 is required, and Azure Automation does not support it yet as of February 2026. For PowerShell 7.4.0+, there are no version restrictions.
    https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3147
    https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3151
    https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/3166
 
    .PARAMETER ExpirationThresholdDays
    (Required when RunFromAzureAutomation is enabled) Number of days threshold for expiration notification. Default is 30 days.
 
    .PARAMETER NotificationRecipient
    (Required when RunFromAzureAutomation is enabled) Email address to receive expiration notifications.
 
    .PARAMETER NotificationSender
    (Required when RunFromAzureAutomation is enabled) Email address of the sender for expiration notifications.
 
    .PARAMETER IncludeSignInStats
    (Optional) If specified, includes sign-in statistics for the last 30 days for each application. Requires AuditLog.Read.All permission.
    Please be advised that this process is time-consuming.
 
    .EXAMPLE
    Get-MgApplicationSAML
 
    Retrieves all Entra ID applications configured for SAML SSO.
 
    .EXAMPLE
    Get-MgApplicationSAML -IncludeSignInStats
 
    Retrieves all Entra ID applications configured for SAML SSO with sign-in statistics for the last 30 days.
 
    .EXAMPLE
    Get-MgApplicationSAML -ObjectID "xxx-xxx-xxx"
 
    Retrieves the SAML configuration for a specific application by its ObjectID.
 
    .EXAMPLE
    Get-MgApplicationSAML -DisplayName "My SAML App"
 
    Retrieves the SAML configuration for a specific application by its DisplayName.
 
    .EXAMPLE
    Get-MgApplicationSAML -ForceNewToken
 
    Forces the function to disconnect and reconnect to Microsoft Graph to obtain a new access token.
 
    .EXAMPLE
    Get-MgApplicationSAML -ExportToExcel
 
    Gets all SAML applications and exports them to an Excel file.
 
    .EXAMPLE
    Get-MgApplicationSAML -RunFromAzureAutomation -ExpirationThresholdDays 30 -NotificationRecipient 'admin@company.com' -NotificationSender 'automation@company.com'
 
    Gets all SAML applications using managed identity authentication and sends notification for certificates expiring within 30 days.
 
    .EXAMPLE
    Get-MgApplicationSAML -RunFromAzureAutomation -ExpirationThresholdDays 7 -NotificationRecipient 'admin@company.com' -NotificationSender 'automation@company.com'
 
    Gets all SAML applications using managed identity and sends email notification for certificates expiring within 7 days.
 
    .NOTES
    More information on: https://itpro-tips.com/get-azure-ad-saml-certificate-details/
 
    This function requires the Microsoft.Graph.Beta.Applications module to be installed.
 
    .NOTES
    Limitations:
    The information about the SAML applications clams is not available in the Microsoft Graph API v1 but in https://main.iam.ad.ext.azure.com/api/ApplicationSso/<service-principal-id>/FederatedSsoV2 so we don't get them
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-MgApplicationSAML
#>


function Get-MgApplicationSAML {
    [CmdletBinding(DefaultParameterSetName = 'All')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'ByObjectId')]
        [string]$ObjectID,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByDisplayName')]
        [string]$DisplayName,

        [Parameter(Mandatory = $false)]
        [switch]$ForceNewToken,

        [Parameter(Mandatory = $false)]
        [switch]$ExportToExcel,

        [Parameter(Mandatory = $false)]
        [switch]$RunFromAzureAutomation,

        [Parameter(Mandatory = $false)]
        [int]$ExpirationThresholdDays = 30,

        [Parameter(Mandatory = $false)]
        [string]$NotificationRecipient,

        [Parameter(Mandatory = $false)]
        [string]$NotificationSender,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeSignInStats
    )

    # Validate notification parameters
    if ($RunFromAzureAutomation.IsPresent) {
        if ([string]::IsNullOrWhiteSpace($NotificationRecipient)) {
            Write-Error 'NotificationRecipient parameter is required when RunFromAzureAutomation is enabled.'
            return
        }
        if ([string]::IsNullOrWhiteSpace($NotificationSender)) {
            Write-Error 'NotificationSender parameter is required when RunFromAzureAutomation is enabled.'
            return
        }
        if ($ExpirationThresholdDays -le 0) {
            Write-Error 'ExpirationThresholdDays must be greater than 0 when RunFromAzureAutomation is enabled.'
            return
        }

        try {
            Import-Module 'Microsoft.Graph.Users.Actions' -ErrorAction Stop -ErrorVariable mgGraphMailMissing
        }
        catch {
            if ($mgGraphMailMissing) {
                Write-Warning "Failed to import Microsoft.Graph.Users.Actions module: $($mgGraphMailMissing.Exception.Message)"
            }

            return
        }
    }

    try {
        # At the date of writing (december 2023), PreferredTokenSigningKeyEndDateTime parameter is only on Beta profile
        Import-Module 'Microsoft.Graph.Beta.Applications' -ErrorAction Stop -ErrorVariable mgGraphAppsMissing
    }
    catch {
        if ($mgGraphAppsMissing) {
            Write-Warning "Please install the Microsoft.Graph.Beta.Applications module: $($mgGraphAppsMissing.Exception.Message)"
        }

        return
    }

    $isConnected = $false

    $isConnected = $null -ne (Get-MgContext -ErrorAction SilentlyContinue)

    if ($ForceNewToken.IsPresent) {
        Write-Verbose 'Disconnecting from Microsoft Graph'
        $null = Disconnect-MgGraph -ErrorAction SilentlyContinue
        $isConnected = $false
    }

    $scopes = (Get-MgContext).Scopes

    $permissionsNeeded = @('Application.Read.All')
    if ($RunFromAzureAutomation.IsPresent) {
        $permissionsNeeded += 'Mail.Send'
    }
    if ($IncludeSignInStats.IsPresent) {
        $permissionsNeeded += 'AuditLog.Read.All'
    }

    $permissionMissing = $permissionsNeeded | Where-Object { $_ -notin $scopes }

    if ($permissionMissing) {
        Write-Verbose "You need to have the $($permissionsNeeded -join ',') permission in the current token, disconnect to force getting a new token with the right permissions"
    }

    # Version check for Azure Automation before connecting
    if ($RunFromAzureAutomation.IsPresent) {
        # Only check module version if PowerShell < 7.4 (Azure Automation limitation)
        if ($PSVersionTable.PSVersion -lt [version]'7.4.0') {
            $mgAuth = Get-Module 'Microsoft.Graph.Authentication' -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1
            if ($mgAuth -and [version]$mgAuth.Version -gt [version]'2.25.0') {
                Write-Error "Microsoft.Graph.Authentication v$($mgAuth.Version) is not compatible with Azure Automation on PowerShell $($PSVersionTable.PSVersion). Maximum supported version is 2.25.0. Script execution stopped."
                return
            }
        }
    }

    if (-not $isConnected) {
        if ($RunFromAzureAutomation.IsPresent) {
            Write-Verbose 'Connecting to Microsoft Graph using Managed Identity'
            Connect-MgGraph -Identity -NoWelcome
        }
        else {
            Write-Verbose "Connecting to Microsoft Graph. Scopes: $($permissionsNeeded -join ',')"
            $null = Connect-MgGraph -Scopes $permissionsNeeded -NoWelcome
        }
    }

    [System.Collections.Generic.List[PSCustomObject]]$samlApplicationsArray = @()
    
    # Determine how to search for the Service Principal(s): by ObjectID (GUID), by DisplayName, or all
    if ($ObjectID) {
        $samlApplications = Get-MgBetaServicePrincipal -ServicePrincipalId $ObjectID
        # Verify it's a SAML application
        if ($samlApplications.PreferredSingleSignOnMode -ne 'saml') {
            Write-Warning "The application with ObjectID '$ObjectID' is not configured for SAML SSO (PreferredSingleSignOnMode: $($samlApplications.PreferredSingleSignOnMode))"
            return
        }
    }
    elseif ($DisplayName) {
        $escaped = $DisplayName -replace "'", "''"
        $filter = "DisplayName eq '$escaped' and PreferredSingleSignOnMode eq 'saml'"
        Write-Verbose "Filtering service principals with: $filter"
        $samlApplications = Get-MgBetaServicePrincipal -Filter $filter -All
        
        # If no exact match found, try to find apps where trimmed DisplayName matches
        if (-not $samlApplications) {
            Write-Verbose "No exact match found. Searching for apps with trimmed DisplayName matching '$DisplayName'..."
            $filter = "startswith(DisplayName, '$escaped') and PreferredSingleSignOnMode eq 'saml'"
            $candidateApps = Get-MgBetaServicePrincipal -Filter $filter -All
            
            # Filter in PowerShell to find apps where trimmed name matches
            $samlApplications = $candidateApps | Where-Object { $_.DisplayName.Trim() -eq $DisplayName }
            
            if ($samlApplications) {
                Write-Verbose "Found $($samlApplications.Count) application(s) with trimmed DisplayName matching '$DisplayName'"
            }
        }
    }
    else {
        $samlApplications = Get-MgBetaServicePrincipal -Filter "PreferredSingleSignOnMode eq 'saml'"
    }

    if (-not $samlApplications) {
        Write-Host 'No SAML applications found' -ForegroundColor Yellow
        return
    }

    Write-Host "$($samlApplications.Count) SAML application(s) found" -ForegroundColor Green

    # Calculate date for 30 days ago for sign-in statistics
    $signInStartDate = (Get-Date).AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ')

    if ($IncludeSignInStats.IsPresent) {
        Write-Host 'Retrieving sign-in statistics for each application - this may take several minutes...' -ForegroundColor Yellow
    }

    $appCounter = 0
    foreach ($samlApp in $samlApplications) {
        $appCounter++
        Write-Host "Processing $appCounter/$($samlApplications.Count): $($samlApp.DisplayName)" -ForegroundColor Cyan
        # Reset to $null before each call: prevents previous iteration's value from bleeding through on silent errors
        $ownerObjects = $null
        $ownerString = $null
        $ownerObjects = Get-MgServicePrincipalOwner -ServicePrincipalId $samlApp.Id -ErrorAction SilentlyContinue

        # Build owners string: DisplayName for each owner, joined with '|'
        if ($ownerObjects) {
            $ownerString = ($ownerObjects | ForEach-Object {
                    $props = $_.AdditionalProperties
                    if ($props.ContainsKey('displayName') -and -not [string]::IsNullOrEmpty($props['displayName'])) { $props['displayName'] }
                    else { $_.Id }
                }) -join '|'
        }
        
        # Check for leading/trailing spaces in DisplayName
        $recommendation = $null
        if ($samlApp.DisplayName -ne $samlApp.DisplayName.Trim()) {
            $recommendation = 'DisplayName contains leading or trailing spaces - consider renaming'
            Write-Warning "Application '$($samlApp.DisplayName)' has leading or trailing spaces in the displayName"
        }
        
        # Get sign-in statistics if requested
        $signInCount = $null
        if ($IncludeSignInStats.IsPresent) {
            try {
                $signInFilter = "appId eq '$($samlApp.AppId)' and createdDateTime ge $signInStartDate"
                Write-Verbose "Sign-in filter: $signInFilter"
                $encodedFilter = [uri]::EscapeDataString($signInFilter)
                $uri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$filter=$encodedFilter&`$count=true&`$top=999"
                try{
                    $signInResponse = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = 'eventual' } -ErrorAction Stop -WarningAction Stop
                }
                catch {
                    $signInCount = "Problem to get sign-ins - $($_.Exception.Message)"
                }

                if ($null -ne $signInResponse.'@odata.count') {
                    $signInCount = [int]$signInResponse.'@odata.count'
                } else {
                    $allSignIns = [System.Collections.Generic.List[object]]@()
                    $signInResponse.value | Where-Object { $_.isInteractive -eq $true } | ForEach-Object { $null = $allSignIns.Add($_) }
                    $nextLink = $signInResponse.'@odata.nextLink'
                    while ($nextLink) {
                        $pageResponse = Invoke-MgGraphRequest -Uri $nextLink -Method GET -Headers @{ ConsistencyLevel = 'eventual' }
                        $pageResponse.value | Where-Object { $_.isInteractive -eq $true } | ForEach-Object { $null = $allSignIns.Add($_) }
                        $nextLink = $pageResponse.'@odata.nextLink'
                    }
                    $signInCount = $allSignIns.Count
                }
                
                Write-Verbose "Found $signInCount sign-ins in the last 30 days for $($samlApp.DisplayName)"
            }
            catch {
                Write-Warning "Could not retrieve sign-in statistics for '$($samlApp.DisplayName)': $($_.Exception.Message)"
                $signInCount = $null
            }
        }

        $object = [PSCustomObject][ordered]@{
            DisplayName                         = $samlApp.DisplayName
            Recommendation                      = $recommendation
            Id                                  = $samlApp.Id
            AppId                               = $samlApp.AppId
            EntraUrl                            = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($samlApp.Id)/appId/$($samlApp.AppId)"
            LoginUrl                            = $samlApp.LoginUrl
            LogoutUrl                           = $samlApp.LogoutUrl
            NotificationEmailAddresses          = $samlApp.NotificationEmailAddresses -join '|'
            AppRoleAssignmentRequired           = $samlApp.AppRoleAssignmentRequired
            PreferredSingleSignOnMode           = $samlApp.PreferredSingleSignOnMode
            SamlSigningCertificateEndTime       = $samlApp.PreferredTokenSigningKeyEndDateTime
            # PreferredTokenSigningKeyEndDateTime is date time, compared to now and see it is valid
            SamlSigningCertificateValid         = $samlApp.PreferredTokenSigningKeyEndDateTime -gt (Get-Date)
            SamlSigningCertificateExpiresInDays = if ($samlApp.PreferredTokenSigningKeyEndDateTime) { [int](New-TimeSpan -Start (Get-Date) -End $samlApp.PreferredTokenSigningKeyEndDateTime).TotalDays } else { $null }
            ReplyUrls                           = $samlApp.ReplyUrls -join '|'
            SignInAudience                      = $samlApp.SignInAudience
            Owners                              = $ownerString
        }

        # Add sign-in statistics only if requested
        if ($IncludeSignInStats.IsPresent) {
            $object | Add-Member -MemberType NoteProperty -Name InteractiveSignInsLast30Days -Value $signInCount
        }

        $samlApplicationsArray.Add($object)
    }

    # Check for expiring certificates and send notification if enabled
    if ($RunFromAzureAutomation.IsPresent) {
        $expiringCertificates = $samlApplicationsArray | Where-Object {
            $null -ne $_.SamlSigningCertificateExpiresInDays -and
            $_.SamlSigningCertificateExpiresInDays -le $ExpirationThresholdDays
        }

        # Calculate statistics for different expiration categories
        $expiredCertificates = $samlApplicationsArray | Where-Object {
            $null -ne $_.SamlSigningCertificateExpiresInDays -and $_.SamlSigningCertificateExpiresInDays -le 0
        }
        $certificatesExpiring15Days = $samlApplicationsArray | Where-Object {
            $null -ne $_.SamlSigningCertificateExpiresInDays -and $_.SamlSigningCertificateExpiresInDays -le 15 -and $_.SamlSigningCertificateExpiresInDays -gt 0
        }
        $certificatesExpiring30Days = $samlApplicationsArray | Where-Object {
            $null -ne $_.SamlSigningCertificateExpiresInDays -and $_.SamlSigningCertificateExpiresInDays -le 30 -and $_.SamlSigningCertificateExpiresInDays -gt 0
        }

        Write-Verbose "Sending notification email. Found $($expiringCertificates.Count) SAML certificates expiring within $ExpirationThresholdDays days."

        $expiringCertificates = $expiringCertificates | Sort-Object SamlSigningCertificateExpiresInDays

        $emailBody = @"
<!DOCTYPE html>
<html>
<head>
<title>Microsoft Entra ID SAML Application Certificates Expiration Alert</title>
<style>
    body {
        font-family: Segoe UI, SegoeUI, Roboto, "Helvetica Neue", Arial, sans-serif;
        margin: 0;
        padding: 20px;
        color: #11100f;
        font-size: 14px;
        line-height: 20px;
        background-color: #ffffff;
    }
     
    h2 {
        padding-top: 0;
        margin: 0 0 16px 0;
        font-family: "Segoe UI Semibold", SegoeUISemibold, "Segoe UI", SegoeUI, Roboto, "Helvetica Neue", Arial, sans-serif;
        font-weight: 600;
        font-size: 20px;
        line-height: 28px;
        color: #323130;
    }
     
    table {
        border-spacing: 0;
        border-collapse: collapse;
        width: 100%;
        margin-bottom: 20px;
        background-color: #ffffff;
        border-radius: 8px;
        overflow: hidden;
        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
     
    th {
        vertical-align: middle;
        color: #ffffff;
        background-color: #323130;
        padding: 3px 8px;
        text-align: left;
        font-family: "Segoe UI Semibold", SegoeUISemibold, "Segoe UI", SegoeUI, Roboto, "Helvetica Neue", Arial, sans-serif;
        font-weight: 600;
        font-size: 12px;
        line-height: 16px;
        word-wrap: break-word;
    }
     
    td {
        vertical-align: middle;
        color: #11100f;
        padding: 3px 8px;
        border-bottom: solid 1px #c8c6c4;
        word-wrap: break-word;
        font-size: 12px;
        line-height: 16px;
    }
     
    .critical { background-color: #FFF0F0; color: #A80000; }
    .warning { background-color: #FDEFD0; color: #7A3A00; }
    .caution { background-color: #CCE4FF; color: #003882; }
     
    .footer {
        margin-top: 30px;
        padding: 20px;
        background-color: #faf9f8;
        border-radius: 8px;
        border-top: 3px solid #0078d4;
    }
     
    .footer p {
        margin: 8px 0;
        font-size: 13px;
        color: #605e5c;
    }
     
    .action-required {
        font-weight: 600;
        color: #d73502;
    }
</style>
</head>
<body>
    <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;border-collapse:collapse;margin-bottom:12px;background:transparent;box-shadow:none;" role="presentation">
        <tr>
            <td width="33%" valign="top" style="width:33%;padding:4pt 3pt 4pt 5pt;">
                <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;background:#FFF0F0;border-collapse:collapse;margin-bottom:0;box-shadow:none;" role="presentation">
                    <tr>
                        <td valign="top" style="padding:6pt 8pt 6pt 8pt;border-bottom:none;">
                            <h4 align="center" style="margin:0 0 5pt 0;text-align:center;line-height:14pt;font-size:11pt;font-family:'Segoe UI Semibold',sans-serif;color:#A80000;font-weight:600;">Expired certificates</h4>
                            <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;border-collapse:collapse;margin-bottom:0;background:transparent;box-shadow:none;" role="presentation">
                                <tr>
                                    <td width="50%" valign="top" style="width:50%;padding:2pt 0 2pt 0;text-align:right;border-bottom:none;">
                                        <span style="font-size:18pt;font-family:'Segoe UI',sans-serif;color:#A80000;font-weight:bold;">$($expiredCertificates.Count)</span>
                                    </td>
                                    <td width="50%" valign="middle" style="width:50%;padding:2pt 0 2pt 6pt;font-size:9pt;font-family:'Segoe UI',sans-serif;color:#A80000;border-bottom:none;vertical-align:middle;">
                                        certificates already expired
                                    </td>
                                </tr>
                            </table>
                        </td>
                    </tr>
                </table>
            </td>
            <td width="34%" valign="top" style="width:34%;padding:4pt 3pt 4pt 3pt;">
                <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;background:#FDEFD0;border-collapse:collapse;margin-bottom:0;box-shadow:none;" role="presentation">
                    <tr>
                        <td valign="top" style="padding:6pt 8pt 6pt 8pt;border-bottom:none;">
                            <h4 align="center" style="margin:0 0 5pt 0;text-align:center;line-height:14pt;font-size:11pt;font-family:'Segoe UI Semibold',sans-serif;color:#7A3A00;font-weight:600;">Expiring within 15 days</h4>
                            <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;border-collapse:collapse;margin-bottom:0;background:transparent;box-shadow:none;" role="presentation">
                                <tr>
                                    <td width="50%" valign="top" style="width:50%;padding:2pt 0 2pt 0;text-align:right;border-bottom:none;">
                                        <span style="font-size:18pt;font-family:'Segoe UI',sans-serif;color:#7A3A00;font-weight:bold;">$($certificatesExpiring15Days.Count)</span>
                                    </td>
                                    <td width="50%" valign="middle" style="width:50%;padding:2pt 0 2pt 6pt;font-size:9pt;font-family:'Segoe UI',sans-serif;color:#7A3A00;border-bottom:none;vertical-align:middle;">
                                        certificates expire within 15 days
                                    </td>
                                </tr>
                            </table>
                        </td>
                    </tr>
                </table>
            </td>
            <td width="33%" valign="top" style="width:33%;padding:4pt 5pt 4pt 3pt;">
                <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;background:#CCE4FF;border-collapse:collapse;margin-bottom:0;box-shadow:none;" role="presentation">
                    <tr>
                        <td valign="top" style="padding:6pt 8pt 6pt 8pt;border-bottom:none;">
                            <h4 align="center" style="margin:0 0 5pt 0;text-align:center;line-height:14pt;font-size:11pt;font-family:'Segoe UI Semibold',sans-serif;color:#003882;font-weight:600;">Expiring within 30 days</h4>
                            <table border="0" cellspacing="0" cellpadding="0" width="100%" style="width:100%;border-collapse:collapse;margin-bottom:0;background:transparent;box-shadow:none;" role="presentation">
                                <tr>
                                    <td width="50%" valign="top" style="width:50%;padding:2pt 0 2pt 0;text-align:right;border-bottom:none;">
                                        <span style="font-size:18pt;font-family:'Segoe UI',sans-serif;color:#003882;font-weight:bold;">$($certificatesExpiring30Days.Count)</span>
                                    </td>
                                    <td width="50%" valign="middle" style="width:50%;padding:2pt 0 2pt 6pt;font-size:9pt;font-family:'Segoe UI',sans-serif;color:#003882;border-bottom:none;vertical-align:middle;">
                                        certificates expire within 30 days
                                    </td>
                                </tr>
                            </table>
                        </td>
                    </tr>
                </table>
            </td>
        </tr>
    </table>
     
    <h2>SAML Certificates requiring attention</h2>
    <table>
        <tr>
            <th>Application</th>
            <th>App ID</th>
            <th>Expires In (Days)</th>
            <th>Expiry Date</th>
            <th>Login URL</th>
            <th>Owners</th>
        </tr>
"@


        foreach ($cert in $expiringCertificates) {
            $rowClass = if ($cert.SamlSigningCertificateExpiresInDays -le 0) { 'critical' } elseif ($cert.SamlSigningCertificateExpiresInDays -le 14) { 'warning' } else { 'caution' }
            $expiresInDaysDisplay = if ($cert.SamlSigningCertificateExpiresInDays -lt 0) { "$($cert.SamlSigningCertificateExpiresInDays) (already expired)" } else { $cert.SamlSigningCertificateExpiresInDays }
            $appLink = "<strong style=`"color:#11100f;font-size:12px;line-height:16px;`">$($cert.DisplayName)</strong> <a href=`"$($cert.EntraUrl)`" style=`"text-decoration:none;font-size:14px;line-height:16px;`" title=`"Open in Entra`">&#x1F517;</a>"
            $ownersList = @($cert.Owners -split '\|' | Where-Object { $_ })
            $ownersHtml = if ($ownersList.Count -gt 1) { ($ownersList | ForEach-Object { "- $_" }) -join '<br>' } else { $ownersList -join '' }
            $emailBody += "<tr class=`"$rowClass`"><td>$appLink</td><td>$($cert.AppId)</td><td><strong>$expiresInDaysDisplay</strong></td><td>$($cert.SamlSigningCertificateEndTime)</td><td>$($cert.LoginUrl)</td><td>$ownersHtml</td></tr>"
        }

        if ($expiringCertificates.Count -eq 0) {
            $emailBody += '<tr><td colspan="6" style="text-align:center;padding:12px 8px;color:#605e5c;font-style:italic;">No certificates requiring attention - all SAML signing certificates are healthy.</td></tr>'
        }

        $emailFooter = if ($expiringCertificates.Count -gt 0) {
            '<p class="action-required">Action Required:</p><p>Please review and renew these SAML signing certificates before they expire to avoid authentication disruptions.</p>'
        }
        else {
            '<p style="color:#107C10;font-weight:600;">&#10003; All SAML signing certificates are healthy. No action required at this time.</p>'
        }

        $emailSubject = if ($expiringCertificates.Count -gt 0) {
            "Microsoft Entra ID SAML Certificates Expiring ($($expiringCertificates.Count) certificates)"
        }
        else {
            'Microsoft Entra ID SAML Certificates - All Healthy'
        }

        $emailBody += @"
    </table>
 
    <div class="footer">
        $emailFooter
        <hr style="border: none; border-top: 1px solid #d2d0ce; margin: 15px 0;">
        <p><em>Generated on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') by Get-MgApplicationSAML v0.71.0</em></p>
    </div>
</body>
</html>
"@


        try {
            $params = @{
                Message         = @{
                    Subject      = $emailSubject
                    Body         = @{
                        ContentType = 'HTML'
                        Content     = $emailBody
                    }
                    ToRecipients = @(
                        @{
                            EmailAddress = @{
                                Address = $NotificationRecipient
                            }
                        }
                    )
                }
                SaveToSentItems = 'false'
            }

            Send-MgUserMail -UserId $NotificationSender -BodyParameter $params
            Write-Host -ForegroundColor Green "Expiration notification email sent successfully to $NotificationRecipient"
        }
        catch {
            Write-Warning "Failed to send notification email: $($_.Exception.Message)"
        }
    }

    if ($ExportToExcel.IsPresent) {
        $now = Get-Date -Format 'yyyy-MM-dd_HHmmss'
        $excelFilePath = "$($env:userprofile)\$now-MgApplicationSAML.xlsx"
        Write-Host -ForegroundColor Cyan "Exporting SAML applications to Excel file: $excelFilePath"
        $samlApplicationsArray | Export-Excel -Path $excelFilePath -AutoSize -AutoFilter -WorksheetName 'EntraSAMLApplications'
        Write-Host -ForegroundColor Green 'Export completed successfully!'
    }
    elseif (-not $RunFromAzureAutomation.IsPresent) {
        return $samlApplicationsArray
    }
}