Complex Azure Template Odyssey Part Three: ADFS Server

Posted by Rik Hepworth on Sunday, August 30, 2015

Part One of this series covered the project itself and the overall template structure. Part Two went through how I deploy the Domain Controller in depth. This post will focus on the next server in the chain: The ADFS server that is required to enable authentication in the application which will eventually be installed on this environment.

The Template

The nested deployment template for the ADFS server differs little from my DC template. If anything, it’s even simpler because we don’t have to reconfigure the virtual network after deploying the VM. The screenshot below shots the JSON outline for the template.

adfs template json
adfs template json

You can see that it follows the same pattern as the DC template in part two. I have a VM, a NIC that it depends on and which is attached to our virtual network, and I have VM extensions within the VM itself to enable diagnostics, push a DSC configuration to the VM and execute a custom PowerShell script.

I went through the template construction in detail with the DC, so here I’ll simply show the resources code for you. The VM uses the same Windows Server base image as the DC but doesn’t need the extra disk that we attached to the DC.

"resources": [
    {
        "apiVersion": "2015-05-01-preview",
        "dependsOn": [],
        "location": "[parameters('resourceLocation')]",
        "name": "[variables('vmADFSNicName')]",
        "properties": {
            "ipConfigurations": [
                {
                    "name": "ipconfig1",
                    "properties": {
                        "privateIPAllocationMethod": "Static",
                        "privateIPAddress": "[variables('vmADFSIPAddress')]",
                        "subnet": {
                            "id": "[variables('vmADFSSubnetRef')]"
                        }
                    }
                }
            ]
        },
        "tags": {
            "displayName": "vmADFSNic"
        },
        "type": "Microsoft.Network/networkInterfaces"
    },
    {
        "name": "[variables('vmADFSName')]",
        "type": "Microsoft.Compute/virtualMachines",
        "location": "[parameters('resourceLocation')]",
        "apiVersion": "2015-05-01-preview",
        "dependsOn": [
            "[concat('Microsoft.Network/networkInterfaces/', variables('vmADFSNicName'))]",
        ],
        "tags": {
            "displayName": "vmADFS"
        },
        "properties": {
            "hardwareProfile": {
                "vmSize": "[variables('vmADFSVmSize')]"
            },
            "osProfile": {
                "computername": "[variables('vmADFSName')]",
                "adminUsername": "[parameters('adminUsername')]",
                "adminPassword": "[parameters('adminPassword')]"
            },
            "storageProfile": {
                "imageReference": {
                    "publisher": "[variables('windowsImagePublisher')]",
                    "offer": "[variables('windowsImageOffer')]",
                    "sku": "[variables('windowsImageSKU')]",
                    "version": "latest"
                },
                "osDisk": {
                    "name": "[concat(variables('vmADFSName'), '-os-disk')]",
                    "vhd": {
                        "uri": "[concat('http://', variables('storageAccountName'), '.blob.core.windows.net/', variables('vmStorageAccountContainerName'), '/', variables('vmADFSName'), 'os.vhd')]"
                    },
                    "caching": "ReadWrite",
                    "createOption": "FromImage"
                }
            },
            "networkProfile": {
                "networkInterfaces": [
                    {
                        "id": "[resourceId('Microsoft.Network/networkInterfaces', variables('vmADFSNicName'))]"
                    }
                ]
            }
        },
        "resources": [
            {
                "type": "extensions",
                "name": "IaaSDiagnostics",
                "apiVersion": "2015-06-15",
                "location": "[parameters('resourceLocation')]",
                "dependsOn": [
                    "[concat('Microsoft.Compute/virtualMachines/', variables('vmADFSName'))]"
                ],
                "tags": {
                    "displayName": "[concat(variables('vmADFSName'),'/vmDiagnostics')]"
                },
                "properties": {
                    "publisher": "Microsoft.Azure.Diagnostics",
                    "type": "IaaSDiagnostics",
                    "typeHandlerVersion": "1.4",
                    "autoUpgradeMinorVersion": "true",
                    "settings": {
                        "xmlCfg": "[base64(variables('wadcfgx'))]",
                        "StorageAccount": "[variables('storageAccountName')]"
                    },
                    "protectedSettings": {
                        "storageAccountName": "[variables('storageAccountName')]",
                        "storageAccountKey": "[listKeys(variables('storageAccountid'),'2015-05-01-preview').key1]",
                        "storageAccountEndPoint": "https://core.windows.net/"
                    }
                }
            },
            {
                "type": "Microsoft.Compute/virtualMachines/extensions",
                "name": "[concat(variables('vmADFSName'),'/ADFSserver')]",
                "apiVersion": "2015-05-01-preview",
                "location": "[parameters('resourceLocation')]",
                "dependsOn": [
                    "[resourceId('Microsoft.Compute/virtualMachines', variables('vmADFSName'))]",
                    "[concat('Microsoft.Compute/virtualMachines/', variables('vmADFSName'),'/extensions/IaaSDiagnostics')]"
                ],
                "properties": {
                    "publisher": "Microsoft.Powershell",
                    "type": "DSC",
                    "typeHandlerVersion": "1.7",
                    "settings": {
                        "modulesURL": "[concat(variables('vmDSCmoduleUrl'), parameters('_artifactsLocationSasToken'))]",
                        "configurationFunction": "[variables('vmADFSConfigurationFunction')]",
                        "properties": {
                            "domainName": "[variables('domainName')]",
                            "vmDCName": "[variables('vmDCName')]",
                            "adminCreds": {
                                "userName": "[parameters('adminUsername')]",
                                "password": "PrivateSettingsRef:adminPassword"
                            }
                        }
                    },
                    "protectedSettings": {
                        "items": {
                            "adminPassword": "[parameters('adminPassword')]"
                        }
                    }
                }
            },
            {
                "type": "Microsoft.Compute/virtualMachines/extensions",
                "name": "[concat(variables('vmADFSName'),'/adfsScript')]",
                "apiVersion": "2015-05-01-preview",
                "location": "[parameters('resourceLocation')]",
                "dependsOn": [
                    "[concat('Microsoft.Compute/virtualMachines/', variables('vmADFSName'))]",
                    "[concat('Microsoft.Compute/virtualMachines/', variables('vmADFSName'),'/extensions/ADFSserver')]"
                ],
                "properties": {
                    "publisher": "Microsoft.Compute",
                    "type": "CustomScriptExtension",
                    "typeHandlerVersion": "1.4",
                    "settings": {
                        "fileUris": [
                            "[concat(parameters('_artifactsLocation'),'/AdfsServer.ps1', parameters('_artifactsLocationSasToken'))]",
                            "[concat(parameters('_artifactsLocation'),'/PSPKI.zip', parameters('_artifactsLocationSasToken'))]",
                            "[concat(parameters('_artifactsLocation'),'/tuServDeployFunctions.ps1', parameters('_artifactsLocationSasToken'))]"
                        ],
                        "commandToExecute": "[concat('powershell.exe -file AdfsServer.ps1',' -vmAdminUsername ',parameters('adminUsername'),' -vmAdminPassword ',parameters('adminPassword'),' -fsServiceName ',variables('vmWAPpublicipDnsName'),' -vmDCname ',variables('vmDCName'), ' -resourceLocation "', parameters('resourceLocation'),'"')]"
                    }
                }
            }
        ]
    },
]

The DSC Modules

All the DSC modules I need get zipped into the same archive file which is deployed by each DSC extension to the VMs. I showed you that in part one. For the ADFS server, the extension calls the configuration module DSCvmConfigs.ps1\\ADFSserver (note the escaped slash) – the ADFSserver configuration within my single DSCvmConfigs.ps1 file that holds all my configurations. As with the DC configuration, this is based on stuff held in the SharePoint farm template on GitHub.

configuration ADFSserver {
    param
    (
        [Parameter(Mandatory)]    
        [String]$DomainName,
        [Parameter(Mandatory)]
        [String]$vmDCName,
        [Parameter(Mandatory)]
        [System.Management.Automation.PSCredential]$Admincreds,
        [Int]$RetryCount = 20,
        [Int]$RetryIntervalSec = 30
    )
    Import-DscResource -ModuleName xComputerManagement, xActiveDirectory
    Node localhost
    {
        WindowsFeature ADFSInstall {
            Ensure = "Present"
            Name = "ADFS-Federation"
        }
        WindowsFeature ADPS {
            Name = "RSAT-AD-PowerShell"
            Ensure = "Present"
        }
        xWaitForADDomain DscForestWait
        {
            DomainName = $DomainName
            DomainUserCredential= $Admincreds
            RetryCount = $RetryCount
            RetryIntervalSec = $RetryIntervalSec
            DependsOn = "[WindowsFeature]ADPS"
        }
        xComputer DomainJoin
        {
            Name = $env:COMPUTERNAME
            DomainName = $DomainName
            Credential = New-Object System.Management.Automation.PSCredential ("${DomainName}$($Admincreds.UserName)", $Admincreds.Password)
            DependsOn = "[xWaitForADDomain]DscForestWait"
        }
        LocalConfigurationManager {
            DebugMode = $true
            RebootNodeIfNeeded = $true
        }
    }
}

The DSC for my ADFS server does much less than that of the DC. It installs the Windows features I need (the RSAT-AD-PowerShell tools are needed by the xWaitForADDomain config), makes sure our domain is contactable and joins the server to it. Unfortunately there are no DSC resources around to configure our ADFS server at the moment and whilst I’m happy writing scripts to to that work, I’m less comfortable writing DSC modules right now!

The Custom Scripts

Once our DSC extension has joined the domain and added our features, it’s over to the customscript extension to configure the ADFS service. As with the DC, I copy down the script itself, a file with my own functions in and the PSPKI module.

# 
# AdfsServer.ps1 
# 
param (
    $vmAdminUsername,
    $vmAdminPassword,
    $fsServiceName,
    $vmDCname,
    $resourceLocation
) 
$password = ConvertTo-SecureString 
$vmAdminPassword -AsPlainText -Force 
$credential = New-Object System.Management.Automation.PSCredential("$env:USERDOMAIN$vmAdminUsername", $password) 
Write-Verbose -Verbose "Entering Domain Controller Script" 
Write-Verbose -verbose "Script path: $PSScriptRoot" 
Write-Verbose -Verbose "vmAdminUsername: $vmAdminUsername" 
Write-Verbose -Verbose "vmAdminPassword: $vmAdminPassword" 
Write-Verbose -Verbose "fsServiceName: $fsServiceName" 
Write-Verbose -Verbose "env:UserDomain: $env:USERDOMAIN" 
Write-Verbose -Verbose "resourceLocation: $resourceLocation"
Write-Verbose -Verbose "==================================="
# Write an event to the event log to say that the script has executed.
$event = New-Object System.Diagnostics.EventLog("Application")
$event.Source = "tuServEnvironment"
$info_event = [System.Diagnostics.EventLogEntryType]::Information
$event.WriteEntry("ADFSserver Script Executed", $info_event, 5001)
$srcPath = "" + $vmDCname + "src"
$fsCertificateSubject = $fsServiceName + "." + ($resourceLocation.Replace(" ", [System.String]::Empty)).ToLower() + ".cloudapp.azure.com"
$fsCertFileName = $fsCertificateSubject + ".pfx"
$certPath = $srcPath + "" + $fsCertFileName
#Copy cert from DC
write-verbose -Verbose "Copying $certpath to $PSScriptRoot"
#
$powershellCommand = "& {copy-item '" + $certPath + "' '" + $workingDir + "'}" 
#  
Write-Verbose -Verbose $powershellCommand
#
$bytes = [System.Text.Encoding]::Unicode.GetBytes($powershellCommand)
#
$encodedCommand = [Convert]::ToBase64String($bytes)  
#
Start-Process -wait "powershell.exe" -ArgumentList "-encodedcommand $encodedCommand"
copy-item $certPath -Destination $PSScriptRoot -Verbose 
Invoke-Command  -Credential $credential -ComputerName $env:COMPUTERNAME -ScriptBlock {
    param (
        $workingDir,
        $vmAdminPassword,
        $domainCredential,
        $fsServiceName,
        $vmDCname,
        $resourceLocation
    )
    # Working 
    Write-Verbose -Verbose "Entering ADFS Script"
    Write-Verbose -verbose "workingDir: $workingDir"
    Write-Verbose -Verbose "vmAdminPassword: $vmAdminPassword"
    Write-Verbose -Verbose "fsServiceName: $fsServiceName"
    Write-Verbose -Verbose "env:UserDomain: $env:USERDOMAIN"
    Write-Verbose -Verbose "env:UserDNSDomain: $env:USERDNSDOMAIN"
    Write-Verbose -Verbose "env:ComputerName: $env:COMPUTERNAME"
    Write-Verbose -Verbose "resourceLocation: $resourceLocation"
    Write-Verbose -Verbose "==================================="
    # Write an event to the event log to say that the script has executed.
    $event = New-Object System.Diagnostics.EventLog("Application")
    $event.Source = "tuServEnvironment"
    $info_event = [System.Diagnostics.EventLogEntryType]::Information
    $event.WriteEntry("In ADFSserver scriptblock", $info_event, 5001)
    #go to our packages scripts folder
    Set-Location $workingDir
    $zipfile = $workingDir + "PSPKI.zip"
    $destination = $workingDir
    [System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
    [System.IO.Compression.ZipFile]::ExtractToDirectory($zipfile, $destination)
    Write-Verbose -Verbose "Importing PSPKI"
    Import-Module .tuServDeployFunctions.ps1
    $fsCertificateSubject = $fsServiceName + "." + ($resourceLocation.Replace(" ", "")).ToLower() + ".cloudapp.azure.com"
    $fsCertFileName = $workingDir + "" + $fsCertificateSubject + ".pfx"
    Write-Verbose -Verbose "Importing sslcert $fsCertFileName"
    Import-SSLCertificate -certificateFileName $fsCertFileName -certificatePassword $vmAdminPassword
    $adfsServiceAccount = $env:USERDOMAIN + "" + "svc_adfs"
    $adfsPassword = ConvertTo-SecureString $vmAdminPassword -AsPlainText -Force
    $adfsCredentials = New-Object System.Management.Automation.PSCredential ($adfsServiceAccount, $adfsPassword)
    $adfsDisplayName = "ADFS Service"
    Write-Verbose -Verbose "Creating ADFS Farm"
    Create-ADFSFarm -domainCredential $domainCredential -adfsName $fsCertificateSubject -adfsDisplayName $adfsDisplayName -adfsCredentials $adfsCredentials -certificateSubject $fsCertificateSubject
} -ArgumentList $PSScriptRoot, $vmAdminPassword, $credential, $fsServiceName, $vmDCname, $resourceLocation

The script starts by copying the certificate files from the DC. The script extension shells the script as the local system account, so it connects to the share on the DC as the computer account. I copy the files before I execute an invoke-command block that run as the domain admin. I do this because once I’m in that invoke-command block, network access becomes a real pain!

As you can see, this script doesn’t do a huge amount. Once in the invoke-command it unzips the PSPKI modules, imports the certificate it needs into the computer cert store and then calls a function to configure the ADFS service. The functions called by the script are below:

function Import-SSLCertificate {
    [CmdletBinding()]
    param
    (
        $certificateFileName,
        $certificatePassword
    )
    Write-Verbose -Verbose "Importing cert $certificateFileName with password $certificatePassword"
    Write-Verbose -Verbose "---"
    Import-Module .PSPKIpspki.psm1
    Write-Verbose -Verbose "Attempting to import certificate" $certificateFileName
    # import it
    $password = ConvertTo-SecureString $certificatePassword -AsPlainText -Force
    Import-PfxCertificate -FilePath ($certificateFileName) cert:localMachinemy -Password $password
}
function Create-ADFSFarm {
    [CmdletBinding()]
    param (
        $domainCredential,
        $adfsName,
        $adfsDisplayName,
        $adfsCredentials,
        $certificateSubject
    )
    Write-Verbose -Verbose "In Function Create-ADFS Farm"
    Write-Verbose -Verbose "Parameters:"
    Write-Verbose -Verbose "adfsName: $adfsName"
    Write-Verbose -Verbose "certificateSubject: $certificateSubject"
    Write-Verbose -Verbose "adfsDisplayName: $adfsDisplayName"
    Write-Verbose -Verbose "adfsCredentials: $adfsCredentials"
    Write-Verbose -Verbose "============================================"
    Write-Verbose -Verbose "Importing Module"
    Import-Module ADFS
    Write-Verbose -Verbose "Getting Thumbprint"
    $certificateThumbprint = (get-childitem Cert:LocalMachineMy | where { $_.subject -match $certificateSubject } | Sort-Object -Descending NotBefore)[0].thumbprint
    Write-Verbose -Verbose "Thumprint is $certificateThumbprint"
    Write-Verbose -Verbose "Install ADFS Farm"
    Write-Verbose -Verbose "Echo command:"
    Write-Verbose -Verbose "Install-AdfsFarm -credential $domainCredential -CertificateThumbprint $certificateThumbprint -FederationServiceDisplayName '$adfsDisplayName' -FederationServiceName $adfsName -ServiceAccountCredential $adfsCredentials"
    Install-AdfsFarm -credential $domainCredential -CertificateThumbprint $certificateThumbprint -FederationServiceDisplayName "$adfsDisplayName" -FederationServiceName $adfsName -ServiceAccountCredential $adfsCredentials -OverwriteConfiguration
}

There’s still stuff do do on the ADFS server once I get to deploying my application: I need to define relying party trusts and custom claims, for example. However, this deployment creates a working ADFS server that will authenticate users against my domain. It’s then published to the outside world safely by the Web Application Proxy role on my WAP server.

Credit Where It’s Due

Same as before – I stand on the shoulders of others to bring you this stuff:

  • The Azure Quick Start templates were invaluable in having something to look at in the early days of resource templates before the tooling was here.
  • PSPKI is a fantastic set of PowerShell modules for dealing with certs.
  • The individual VM scripts are derived from work that was done in Black Marble by Andrew Davidson and myself to build the exact same environment in the older Azure manner without resource templates.