src/common/GraphApplicationCertificate.ps1

# Copyright 2021, Adam Edwards
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

. (import-script LocalCertificate)

ScriptClass GraphApplicationCertificate {

    $AppId = $null
    $ObjectID = $null
    $DisplayName = $null
    $CertLocation = $null
    $X509Certificate = $null
    $NotBefore = $null
    $validityTimeSpan = $null
    $keyLength = $null
    $certificateFilePath = $null

    static {
        const __AppCertificateSubjectParent 'CN=AutoGraphPS, CN=MicrosoftGraph'
        const DEFAULT_KEY_LENGTH 4096

        function FindAppCertificate(
            $AppId,
            $CertStoreLocation = 'cert:/currentuser/my',
            $Name,
            $ObjectId
        ) {
            $argCount = if ( $AppId ) { 1 } else { 0 }
            $argCount += if ( $ObjectId ) { 1 } else { 0 }
            $argCount += if ( $Name ) { 1 } else { 0 }

            if ( $argCount -gt 1 ) {
                throw "Only one of appid or name may be specified to search for certificates"
            }

            $certs = ls $CertStoreLocation

            if ( $AppId ) {
                $subject = __GetAppCertificateSubject $appId
                $certs | where subject -eq $subject
            } else {
                $searchTarget = if ( $ObjectId )  {
                    __GetAppCertificateObjectIdComponent $ObjectId
                } elseif ( $Name ) {
                    __GetAppCertificateDisplayNameComponent $Name
                }

                $subjectSuffix = $this.__AppCertificateSubjectParent
                $certs | where {
                    $_.subject.endswith($subjectSuffix) -and
                    ( ! $searchTarget -or ( $_.FriendlyName.tolower().contains($searchTarget.tolower()) ) )
                }
            }
        }

        function LoadFrom($appId, $objectId, [string] $certificatePath, [string] $certStoreLocation, [PSCredential] $certCredential) {
            $certificate = new-so GraphApplicationCertificate $appId $objectId $null $null $null $certStoreLocation $certificatePath
            $certificate |=> __Load $certCredential
            $certificate
        }

        function __GetAppCertificateSubject($appId) {
            "CN={0}, $($this.__AppCertificateSubjectParent)" -f $appId
        }

        function __GetAppCertificateDisplayNameComponent($name) {
            "name='$name'"
        }

        function __GetAppCertificateObjectIdComponent($objectId) {
            "objectId=$objectId"
        }

        function __GetAppCertificateFriendlyName($appId, $name, $objectId) {
            $nameComponent = __GetAppCertificateDisplayNameComponent $name
            $objectIdComponent = __GetAppCertificateObjectIdComponent $objectId
            "Credential for Microsoft Graph Entra ID application $nameComponent, appId=$appId, $objectIdComponent"
        }
    }

    function __initialize($appId, $objectId, $displayName, $validityTimeSpan, $notBefore, $certStoreLocation = 'cert:/currentuser/my', $certificateFilePath, $keyLength) {
        $this.ObjectId = $objectId
        $this.AppId = $appId
        $this.CertLocation = $certStoreLocation
        $this.DisplayName = $displayName
        $this.NotBefore = $NotBefore
        $this.validityTimeSpan = $validityTimeSpan
        $this.certificateFilePath = $certificateFilePath
        $this.keyLength = $keyLength
    }

    function Create {
        $::.LocalCertificate |=> ValidateCertificateCreationCapability

        if ( $this.certificateFilePath ) {
            throw "The certificate cannot be created because it was already loaded from the file '$($this.certificateFilePath)'"
        }

        if ( $this.X509Certificate ) {
            throw 'Certificate already created'
        }

        $certStoreDestination = $this.CertLocation

        $description = $this.scriptclass |=> __GetAppCertificateFriendlyName $this.AppId $this.DisplayName $this.ObjectId
        $subject = $this.scriptclass |=> __GetAppCertificateSubject $this.AppId

        $notBefore = if ( $this.NotBefore ) {
            $this.NotBefore.ToLocalTime()
        } else {
            ([datetime]::Now - [TimeSpan]::FromMinutes(1)).ToLocalTime()
        }

        $validityTimeSpan = if ( $this.validityTimeSpan ) {
            $this.validityTimeSpan
        } else {
            [TimeSpan]::FromDays(365)
        }

        $notAfter = $notBefore + $validityTimeSpan

        $keyLength = if ( $this.keyLength ) {
            $this.keyLength
        } else {
            $this.scriptclass.DEFAULT_KEY_LENGTH
        }

        write-verbose "Creating certificate with subject '$subject'"

        $this.X509Certificate = New-SelfSignedCertificate -Subject $subject -friendlyname $description -provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' -CertStoreLocation $certStoreDestination -NotBefore $notBefore -NotAfter $notAfter -KeyLength $keyLength
    }

    function GetEncodedPublicCertificateData {
        $::.LocalCertificate |=> GetEncodedPublicCertificateData $this.X509Certificate
    }

    function GetEncodedCertificateThumbprint {
        $::.LocalCertificate |=> GetEncodedCertificateThumbprint $this.X509Certificate
    }

    function Export($outputDirectory, [string] $certificateFilePath, [SecureString] $certPassword) {
        $destination = if ( ! $certificateFilePath ) {
            join-path $outputDirectory "GraphApp-$($this.appid).pfx"
        } else {
            $parent = split-path -parent $certificateFilePath

            if ( $parent -and ( ! ( test-path $parent ) -and ( $parent.Contains('/') -or $parent.Contains('\') ) ) ) {
                throw "The directory that contains the specified path '$certificateFilePath' does not exist"
            }
            $certificateFilePath
        }

        if ( test-path $destination ) {
            throw [ArgumentException]::new("An exported certificate for appid '$($this.appid)' already exists at the specified Directory location '$outputDirectory'")
        }

        $content = if ( $certPassword ) {
            $this.X509Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $certPassword)
        } else {
            $this.X509Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx)
        }

        $byteStreamOutputParameter = if ( $PSEdition -eq 'Core' ) {
            @{AsByteStream = [System.Management.Automation.SwitchParameter]::new($true)}
        } else {
            @{Encoding = 'Byte'}
        }

        $content | Set-Content @byteStreamOutputParameter $destination

        ( Get-Item $destination ).FullName
    }

    function __Load([PSCredential] $certCredential) {
        if ( $this.X509Certificate ) {
            throw "The certificate has already been loaded from path '$($this.certificateFilePath)'"
        }

        $certPassword = if ( $certCredential ) {
            $certCredential.Password
        }

        $certificateObject = $::.LocalCertificate |=> GetCertificateFromPath $this.certificateFilePath $null $false $certPassword
        $displayName = $certificateObject.FriendlyName

        $validityTimeSpan = $certificateObject.NotAfter - $certificateObject.NotBefore

        $this.X509Certificate = $certificateObject
        $this.ObjectID = $objectId
        $this.DisplayName = $displayName
        $this.validityTimeSpan = $validityTimeSpan
        $this.NotBefore = $certificateObject.NotBefore
        $this.CertLocation = $null
    }
}