Complex Azure Template Odyssey Part Three: ADFS Server
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.
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.
1"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.
1configuration 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.
1\# # 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 variables 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:
1function 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 }
2```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.
3
4Credit Where It’s Due
5---------------------
6
7Same as before – I stand on the shoulders of others to bring you this stuff:
8
9* The [Azure Quick Start templates](http://azure.microsoft.com/en-us/documentation/templates/) were invaluable in having something to look at in the early days of resource templates before the tooling was here.
10* [PSPKI](https://pspki.codeplex.com/) is a fantastic set of PowerShell modules for dealing with certs.
11* The individual VM scripts are derived from work that was done in [Black Marble](http://www.blackmarble.com/) by [Andrew Davidson](http://blogs.blackmarble.co.uk/blogs/adavidson) and myself to build the exact same environment in the older Azure manner without resource templates.