PSMSALNet.psm1
#Region '.\prefix.ps1' -1 # This is required to expose in a private way the cmdlet Get-WAMToken which is required when we use the -WAMFlow import-module $(Join-Path $PSScriptRoot 'lib' 'WAMHelper.dll') -ErrorAction Stop #EndRegion '.\prefix.ps1' 3 #Region '.\Public\ConvertFrom-Jwt.ps1' -1 function ConvertFrom-Jwt { <# .SYNOPSIS This function will decode a base64 JWT token. .DESCRIPTION Big thank you to both Darren Robinson (https://github.com/darrenjrobinson/JWTDetails/blob/master/JWTDetails/1.0.0/JWTDetails.psm1) and Mehrdad Mirreza in the comment of the blog post (https://www.michev.info/Blog/Post/2140/decode-jwt-access-and-id-tokens-via-powershell) I've used both article for inspiration because: Darren does not have header wich is a mandatory peace according to me and Mehrdad does not have signature which is also a mandatory piece. .PARAMETER Token Specify the access token you want to decode .EXAMPLE PS> ConvertFrom-Jwt -Token "ey...." "will decode the token" .NOTES VERSION HISTORY 1.0 | 2021/07/06 | Francois LEON initial version POSSIBLE IMPROVEMENT - #> [cmdletbinding()] param ( [Parameter(Mandatory = $true)] [string]$Token ) Write-Verbose "[$((Get-Date).TimeofDay)] Starting $($myinvocation.mycommand)" Write-Verbose "[$((Get-Date).TimeofDay)] $($myinvocation.mycommand) - Remove Bearer word just in case" $Token = $Token.Replace('Bearer ', '') try { # Validate as per https://tools.ietf.org/html/rfc7519 # Access and ID tokens are fine, Refresh tokens will not work if (!$Token.Contains('.') -or !$Token.StartsWith('eyJ')) { Throw 'Invalid token' } # Extract header and payload $tokenheader, $tokenPayload, $tokensignature = $Token.Split('.').Replace('-', '+').Replace('_', '/')[0..2] # Fix padding as needed, keep adding '=' until string length modulus 4 reaches 0 while ($tokenheader.Length % 4) { Write-Debug 'Invalid length for a Base-64 char array or string, adding ='; $tokenheader += '=' } while ($tokenPayload.Length % 4) { Write-Debug 'Invalid length for a Base-64 char array or string, adding ='; $tokenPayload += '=' } while ($tokenSignature.Length % 4) { Write-Debug 'Invalid length for a Base-64 char array or string, adding ='; $tokenSignature += '=' } Write-Verbose "[$((Get-Date).TimeofDay)] $($myinvocation.mycommand) - Base64 encoded (padded) header:`n$tokenheader" Write-Verbose "[$((Get-Date).TimeofDay)] $($myinvocation.mycommand) - Base64 encoded (padded) payoad:`n$tokenPayload" Write-Verbose "[$((Get-Date).TimeofDay)] $($myinvocation.mycommand) - Base64 encoded (padded) payoad:`n$tokenSignature" # Convert header from Base64 encoded string to PSObject all at once $header = [System.Text.Encoding]::ASCII.GetString([system.convert]::FromBase64String($tokenheader)) | ConvertFrom-Json # Convert payload to string array $tokenArray = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($tokenPayload)) # Convert from JSON to PSObject $tokobj = $tokenArray | ConvertFrom-Json # Convert Expiry time to PowerShell DateTime $orig = (Get-Date -Year 1970 -Month 1 -Day 1 -hour 0 -Minute 0 -Second 0 -Millisecond 0) $timeZone = Get-TimeZone $utcTime = $orig.AddSeconds($tokobj.exp) $hoursOffset = $timeZone.GetUtcOffset($(Get-Date)).hours #Daylight saving needs to be calculated $localTime = $utcTime.AddHours($hoursOffset) # Return local time, # Time to Expiry $timeToExpiry = ($localTime - (get-date)) Write-Verbose "[$((Get-Date).TimeofDay)] Ending $($myinvocation.mycommand)" [pscustomobject]@{ Tokenheader = $header TokenPayload = $tokobj TokenSignature = $tokenSignature TokenExpiryDateTime = $localTime TokentimeToExpiry = $timeToExpiry } } catch { $_.Exception.Message } } #EndRegion '.\Public\ConvertFrom-Jwt.ps1' 98 #Region '.\Public\ConvertTo-X509Certificate2.ps1' -1 function ConvertTo-X509Certificate2 { <# .SYNOPSIS This function will output a X509Certificate2 certificate. .DESCRIPTION Because Linux does not have the Cert: provider (Get-PsProvider), we have to find a way to use the provided certificate on all platforms and X509 is a solution. This function will inject several format (cer,crt,pem,pfx) to expose the result in a standardized way. .PARAMETER PfxPath Specify path of a pfx file. .PARAMETER PemPath Specify path of a pem file. .PARAMETER CerPath Specify path of a cer file. .PARAMETER CrtPath Specify path of a crt file. .PARAMETER Password Specify password of a pfx file. .PARAMETER PrivateKeyPath Specify path of a decrypted private key. .PARAMETER KeyVaultCertificatePath Specify path of a specific certificate version hosted on Azure Key Vault. .PARAMETER AccessToken Specify an access token to contact the associated Key Vault. .PARAMETER APIVersion Specify the API version regarding Keyvault API for now the default value is 7.3. .PARAMETER ExportPrivateKey Specify you want to extract from Key Vault the certificate with the Private key. .EXAMPLE $PubCert = ConvertTo-X509Certificate2 -CerPath ./scomnewbie.cer Will generate a X509Certificate2 without private key from a cer file. .EXAMPLE $PubCert = ConvertTo-X509Certificate2 -CerPath ./scomnewbie.crt Will generate a X509Certificate2 without private key from a crt file. .EXAMPLE $PrivCert = ConvertTo-X509Certificate2 -PfxPath ./scomnewbie.pfx -Password $(ConvertTo-SecureString -String "exportpassword" -AsPlainText -Force) $PrivCert.PrivateKey Will generate a X509Certificate2 with private key from a pfx file. .EXAMPLE $PrivCert = ConvertTo-X509Certificate2 -PemPath ./scomnewbie2.pem -PrivateKeyPath ./privatekey_rsa.key $PrivCert.PrivateKey Will generate a X509Certificate2 with private key from a pem file. .EXAMPLE $CertURL = 'https://<myvault>.vault.azure.net/certificates/test/5d69153b75214245ab72fa21b9c06bfb' $KVToken = (Get-AzAccessToken -Resource "https://vault.azure.net").Token #Once authenticated to Azure ConvertTo-X509Certificate2 -KeyVaultCertificatePath $CertURL -AccessToken $KVToken Will generate a X509Certificate2 with public key from a certificate hosted in Key Vault. .EXAMPLE $CertURL = 'https://<myvault>.vault.azure.net/certificates/test/5d69153b75214245ab72fa21b9c06bfb' $KVToken = (Get-AzAccessToken -Resource "https://vault.azure.net").Token #Once authenticated to Azure ConvertTo-X509Certificate2 -KeyVaultCertificatePath $CertURL -AccessToken $KVToken -ExportPrivateKey Will generate a X509Certificate2 with private key from a certificate hosted in Key Vault. .NOTES VERSION HISTORY 1.0 | 2023/10/03 | Francois LEON initial version POSSIBLE IMPROVEMENT - #> [CmdletBinding()] [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] #[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword","")] param( [parameter(Mandatory, ParameterSetName = 'pfx')] [ValidateScript({ if ((Test-Path $_) -AND ($_ -like '*.pfx')) { $true } else { throw "Path $_ is not valid" } })] [String]$PfxPath, [parameter(Mandatory, ParameterSetName = 'pem')] [ValidateScript({ if ((Test-Path $_) -AND ($_ -like '*.pem')) { $true } else { throw "Path $_ is not valid" } })] [String]$PemPath, [parameter(Mandatory, ParameterSetName = 'crt')] [ValidateScript({ if ((Test-Path $_) -AND ($_ -like '*.crt')) { $true } else { throw "Path $_ is not valid" } })] [String]$CrtPath, [parameter(Mandatory, ParameterSetName = 'cer')] [ValidateScript({ if ((Test-Path $_) -AND ($_ -like '*.cer')) { $true } else { throw "Path $_ is not valid" } })] [String]$CerPath, [parameter(ParameterSetName = 'pfx')] [ValidateScript({ if ($_.Length -gt 0) { $true } else { throw 'SecureString argument contained no data.' } })] [securestring]$Password, [parameter(ParameterSetName = 'pem')] [ValidateScript({ if ((Test-Path $_) -AND ( $(Get-Content $_ | Select-Object -First 1) -eq '-----BEGIN PRIVATE KEY-----' )) { $true } else { throw "Path $_ is not valid or private key is not visible" } })] [string]$PrivateKeyPath, [parameter(Mandatory, ParameterSetName = 'keyvault')] [string]$KeyVaultCertificatePath, #https://ubuntukv415745.vault.azure.net/certificates/test/5d69153b75214245ab72fa21b9c06bfb [parameter(Mandatory, ParameterSetName = 'keyvault')] [string]$AccessToken, #(Get-AzAccessToken -Resource "https://vault.azure.net").Token [parameter(ParameterSetName = 'keyvault')] [string]$APIVersion = '7.3', [parameter(ParameterSetName = 'keyvault')] [switch]$ExportPrivateKey ) Begin { Write-Verbose "[$((Get-Date).TimeofDay)] Starting $($myinvocation.mycommand)" # Just keep what we need to avoid useless switch iteration $PSBoundParameters.Remove('KeyVaultAccessToken') | Out-Null $PSBoundParameters.Remove('ExportPrivateKey') | Out-Null $PSBoundParameters.Remove('PrivateKeyPath') | Out-Null $PSBoundParameters.Remove('Password') | Out-Null $PSBoundParameters.Remove('AccessToken') | Out-Null $PSBoundParameters.Remove('APIVersion') | Out-Null $PSBoundParameters.Remove('ExportPrivateKey') | Out-Null } #begin Process { switch ($PSBoundParameters.Keys) { 'CerPath' { #Even if it's not the same format crt and cer is using the same method. I will duplicate code for readability. [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($(Get-Item -Path $CerPath)) break } 'CrtPath' { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($(Get-Item -Path $CrtPath)) break } 'PfxPath' { if ($Password) { #Means private key protected by password # Means Linux/Windows/MacOS running on Powershell 7 (Yes v6 does not count :D) [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($(Get-Item -Path $PfxPath), $(ConvertFrom-SecureString -SecureString $Password -AsPlainText)) break } else { #Means no password to protect the private key [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($(Get-Item -Path $PfxPath)) break } } 'PemPath' { if ($PrivateKeyPath) { #Means private key protected by password #openssl pkcs12 -in ./scomnewbie.pfx -out ./scomnewbie2.pem # Privatekey will be encrypted + no -nodes means passphrase required #openssl rsa -in ./scomnewbie2.pem -out privatekey_rsa.key #Enter passphrase + decode PK [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPemFile($(Get-Item -Path $PemPath), $(Get-Item -Path $PrivateKeyPath)) break } else { #Means no password to protect the private key #openssl pkcs12 -in ./scomnewbie.pfx -out ./scomnewbie.pem -nodes # WARNING No more password anymore + PK decoded if ($(Get-Content -Path $PemPath) -match '-----BEGIN PRIVATE KEY-----') { [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPemFile($(Get-Item -Path $PemPath)) # Make sure private key is not encrypted! break } else { throw "Make sure you're private key is not encrypted" } } } 'KeyVaultCertificatePath' { if ($ExportPrivateKey) { $CertInfo = Get-KVCertificateWithPrivateKey -KeyVaultCertificatePath $KeyVaultCertificatePath -AccessToken $AccessToken -APIVersion $APIVersion $pfxUnprotectedBytes = [Convert]::FromBase64String($CertInfo.value) [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfxUnprotectedBytes) } else { $CertInfo = Get-KVCertificateWithPublicKey -KeyVaultCertificatePath $KeyVaultCertificatePath -AccessToken $AccessToken -APIVersion $APIVersion if ($IsWindows) { $cBytes = [System.Text.Encoding]::UTF8.GetBytes($CertInfo.cer) [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cBytes) } else { $ModCertInfo = @" -----BEGIN CERTIFICATE----- $($CertInfo.cer) -----END CERTIFICATE----- "@ $cBytes = [System.Text.Encoding]::UTF8.GetBytes($ModCertInfo) [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cBytes) } } } }#end switch } #process End { Write-Verbose "[$((Get-Date).TimeofDay)] Ending $($myinvocation.mycommand)" } #end } #EndRegion '.\Public\ConvertTo-X509Certificate2.ps1' 275 #Region '.\Public\Get-EntraToken.ps1' -1 function Get-EntraToken { <# .SYNOPSIS This function will interact with MSAL. .DESCRIPTION Use this function to generate JWT Entra tokens. By default the token cache will be in memory. .PARAMETER ClientCredentialFlowWithSecret The ClientCredentialFlowWithSecret parameter defines you want to generate an entra token with the client credential flow with secrets. .PARAMETER ClientCredentialFlowWithCertificate The ClientCredentialFlowWithCertificate parameter defines you want to generate an entra token with the client credential flow with certificates. .PARAMETER PublicAuthorizationCodeFlow The PublicAuthorizationCodeFlow parameter defines you want to generate an entra token with the Authorization Code flow with PKCE. No secrets are required. .PARAMETER DeviceCodeFlow The DeviceCodeFlow parameter defines you want to generate an entra token with the Device code flow. No secrets are required. .PARAMETER WAMFlow The WAMFlow parameter defines you want to generate an entra token with the Windows WAM (Web Account Manager). No secrets are required. .PARAMETER OnBehalfFlowWithSecret The OnBehalfFlowWithSecret parameter defines you want to generate an entra token with the On behalf flows with secrets. .PARAMETER OnBehalfFlowWithCertificate The OnBehalfFlowWithCertificate parameter defines you want to generate an entra token with the On behalf flows with certificates. .PARAMETER FederatedCredentialFlowWithAssertion The FederatedCredentialFlowWithAssertion parameter defines you want to generate an entra token with the federated credential authentication method. A token assertion is required. .PARAMETER UserAssertion The UserAssertion parameter defines the token you want to use in both the OBO flow or the federated credential flow. .PARAMETER SystemManagedIdentity The SystemManagedIdentity parameter defines the token you want to generate an entra token with the system managed identity. .PARAMETER UserManagedIdentity The UserManagedIdentity parameter defines the token you want to generate an entra token with the user managed identity. .PARAMETER ClientId The ClientId parameter defines the client Id (application Id) you want to use to generate a token. .PARAMETER ClientSecret The ClientSecret parameter defines the client secret you want to use in your authentication flow. .PARAMETER ClientCertificate The ClientCertificate parameter defines the client certificate you want to use in your authentication flow. .PARAMETER WithoutCaching The WithoutCaching parameter defines the you want to force a token refresh instead of using the MSAL cache. .PARAMETER AzureCloudInstance The AzureCloudInstance parameter defines the Azure environment you plan to consume. By default, the module target Azure public. .PARAMETER TenantId The TenantId parameter defines the Entra tenant Id. If you don't specificy this parameter, the common authority will be used (multi-tenants applications) .PARAMETER RedirectUri The RedirectUri parameter defines the redirect uri required for several public workflow. By default this parameter equal http://localhost. .PARAMETER Resource The Resource parameter defines the resource you want to consume. A scope is composed of a resource and a permission. This parameter is pre-filled with Azure audiences like Graph API, KeyVault or Custom (your API) .PARAMETER CustomResource The CustomResource parameter defines your custom resource you exposed in Entra (api://<...>). You have to use it in addition of the Custom Resource parameter value. .PARAMETER Permissions The Permissions parameter defines the permissions you request. This is usually what is after api://<...>/<Permission> or with Graph API @("User.Read","Group.Read"). the combinaison of Resource and permission create the scope. .PARAMETER ExtraScopesToConsent The ExtraScopesToConsent parameter defines the extra scopes you need following the Entra limitation where you can call only one resource per call. Thi parameter is useful when you need Graph API and ARM token. .PARAMETER WithDebugLogging The WithDebugLogging enable the MSAL library logging. .PARAMETER WithLocalCaching The WithLocalCaching enable the MSAL library usage on the filesystem instead of the default memory. The cache file will be called tokens_cache.dat. For Linux, make sure Keyring is installed or use with WithUnprotectedTokenSerialization instead (WSL) .PARAMETER TokenSerializationPath The TokenSerializationPath let you define where to store the cache file. By default it will be in the current directory. .PARAMETER WithUnprotectedTokenSerialization The WithUnprotectedTokenSerialization let you store the generated token in plaintext on the filesystem. .EXAMPLE $HashArguments = @{ ClientId = $clientId ClientSecret = $ClientSecret TenantId = $TenantId Resource = 'GraphAPI' } Get-EntraToken -ClientCredentialFlowWithSecret @HashArguments This command will generate a token to access Graph API scope with all application permissions assign to this app registration. The token is stored in memory cache managed by MSAL. .EXAMPLE $HashArguments = @{ ClientId = $clientId TenantId = $TenantId RedirectUri = 'http://localhost' Resource = 'GraphAPI' Permissions = @('user.read','group.read.all') ExtraScopesToConsent = @('https://management.azure.com/user_impersonation') verbose = $true } Get-EntraToken -PublicAuthorizationCodeFlow @HashArguments This command will generate a token to access Graph API scope with all application permissions added in the request. In addition, the request will do a second call to Entra to generate a token to access the ARM resource. The token is stored in memory cache managed by MSAL. .EXAMPLE Get-EntraToken -DeviceCodeFlow -ClientId $ClientId -TenantId $TenantId -Resource GraphAPI -Permissions @('user.read') This command will generate a token to access Graph API (user.read) scope with the default redirect uri value which is 'http://localhost' .EXAMPLE Get-EntraToken -WAMFlow -ClientId $clientId -TenantId $tenantId -RedirectUri 'ms-appx-web://Microsoft.AAD.BrokerPlugin/9f0...8f01' -Resource Custom -CustomResource api://AADToken-WebAPI-back-OBO -Permissions access_asuser This command (Windows only) use the Web Account MAnager component to communicate with Entra. In this case we request a token to access a custom API protected by entra. The redirect uri is not configured to use localhost as usual. .EXAMPLE $X509 = ConvertTo-X509Certificate2 -PfxPath C:\TEMP\newcert.pfx -Password $(ConvertTo-SecureString -String '{myPassword}' -AsPlainText -Force) -Verbose Get-EntraToken -OnBehalfFlowWithCertificate -ClientCertificate $X509 -UserAssertion $FrontEndClientToken.accesstoken -ClientId $BackendClientId -TenantId $tenantId -Resource GraphAPI -Permissions 'User.read' | % AccessToken This command, executed from a backend api will generate a tokens using the OBO flow with certificate. .EXAMPLE $KubeSaToken = Get-Content -Path '/var/run/secrets/azure/tokens/azure-identity-token' Get-EntraToken -FederatedCredentialFlowWithAssertion -UserAssertion $KubeSaToken -ClientId $([Environment]::GetEnvironmentVariable('AZURE_CLIENT_ID')) -TenantId $([Environment]::GetEnvironmentVariable('AZURE_TENANT_ID')) -Resource GraphAPI This command will generate a token to access Graph API (/.default) scope from a Kubernetes pod. .EXAMPLE $HashArguments = @{ ClientId = $clientId TenantId = $TenantId RedirectUri = 'http://localhost' Resource = 'GraphAPI' Permissions = @('user.read','group.read.all') WithLocalCaching = $true TokenSerializationPath = 'C:\TEMP' verbose = $true } Get-EntraToken -PublicAuthorizationCodeFlow @HashArguments This command will generate a token with auth code flow to access Graph API scope with all application permissions added in the request. The token generated token will be encrypted on the filesystem depending of the operating system capabilities. Windows will use DPAPI, MAC Keychain and for Linux make sure Kyring is installed. .EXAMPLE $HashArguments = @{ ClientId = $clientId TenantId = $TenantId RedirectUri = 'http://localhost' Resource = 'GraphAPI' Permissions = @('user.read','group.read.all') WithLocalCaching = $true WithUnprotectedTokenSerialization = $true verbose = $true } Get-EntraToken -DeviceCodeFlow @HashArguments This command will generate a token with device code flow to access Graph API scope with all application permissions added in the request. The token generated token will be in plaintext on the filesystem (WSL) in the current directory. .EXAMPLE Get-EntraToken -SystemManagedIdentity -Resource GraphAPI -WithDebugLogging -Verbose This command will generate a token from system managed identity with MSAL debug logs .EXAMPLE Get-EntraToken -UserManagedIdentity -ClientId <User assigned MSI AppId> -Resource ARM | % AccessToken | clip This command will generate a token from user managed identity to access the Azure Resource Management scope .NOTES VERSION HISTORY 2023/09/23 | Francois LEON initial version 2023/10/23 | Francois LEON Token serialization Azure ARC tokens 2024/05/10 | Francois LEON Add managed identity examples #> [cmdletbinding()] [OutputType([Microsoft.Identity.Client.AuthenticationResult])] param ( # Identifier of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowSecret')] [switch]$ClientCredentialFlowWithSecret, # Identifier of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowCertificate')] [switch]$ClientCredentialFlowWithCertificate, [Parameter(Mandatory, ParameterSetName = 'PublicAuthorizationCodeFlow')] [switch]$PublicAuthorizationCodeFlow, [Parameter(Mandatory, ParameterSetName = 'DeviceCodeFlow')] [switch]$DeviceCodeFlow, [Parameter(Mandatory, ParameterSetName = 'WAMFlow')] [switch]$WAMFlow, [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithSecret')] [switch]$OnBehalfFlowWithSecret, [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithCertificate')] [switch]$OnBehalfFlowWithCertificate, [Parameter(Mandatory, ParameterSetName = 'FederatedCredentialFlowWithAssertion')] [switch]$FederatedCredentialFlowWithAssertion, [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithCertificate')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithSecret')] [Parameter(Mandatory, ParameterSetName = 'FederatedCredentialFlowWithAssertion')] [string]$UserAssertion, # Identifier of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'SystemManagedIdentity')] [switch]$SystemManagedIdentity, # Identifier of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'UserManagedIdentity')] [switch]$UserManagedIdentity, # Identifier of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowCertificate')] [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowSecret')] [Parameter(Mandatory, ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(Mandatory, ParameterSetName = 'UserManagedIdentity')] [Parameter(Mandatory, ParameterSetName = 'DeviceCodeFlow')] [Parameter(Mandatory, ParameterSetName = 'WAMFlow')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithCertificate')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithSecret')] [Parameter(Mandatory, ParameterSetName = 'FederatedCredentialFlowWithAssertion')] [guid] $ClientId, # Secure secret of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowSecret')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithSecret')] [string] $ClientSecret, # Client assertion certificate of the client requesting the token. [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowCertificate')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithCertificate')] [System.Security.Cryptography.X509Certificates.X509Certificate2] $ClientCertificate, # Will generate a new token on each call with this param [Parameter(ParameterSetName = 'ClientCredentialFlowSecret')] [Parameter(ParameterSetName = 'ClientCredentialFlowCertificate')] [Parameter(ParameterSetName = 'FederatedCredentialFlowWithAssertion')] [switch] $WithoutCaching, # Instance of Azure Cloud [ValidateSet('AzurePublic', 'AzureChina', 'AzureUsGovernment', 'AzureGermany')] [Microsoft.Identity.Client.AzureCloudInstance] $AzureCloudInstance = 'AzurePublic', # Tenant identifier of the authority to issue token. It can also contain the value "consumers" or "organizations". [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowCertificate')] [Parameter(Mandatory, ParameterSetName = 'ClientCredentialFlowSecret')] [Parameter(ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(ParameterSetName = 'DeviceCodeFlow')] [Parameter(ParameterSetName = 'WAMFlow')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithCertificate')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithSecret')] [Parameter(Mandatory, ParameterSetName = 'FederatedCredentialFlowWithAssertion')] [guid] $TenantId, # Address to return to upon receiving a response from the authority. [Parameter(ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(ParameterSetName = 'DeviceCodeFlow')] [Parameter(ParameterSetName = 'WAMFlow')] [uri] $RedirectUri = 'http://localhost', #Scope = Resource + Permission [parameter(Mandatory)] [ValidateSet('Keyvault', 'ARM', 'GraphAPI', 'Storage', 'Monitor', 'LogAnalytics', 'PostGreSql', 'Custom')] #TODO: valider Graph API not sure it's working [string] $Resource, [string] $CustomResource = $null, #https:// ... should be used only with Custom Audience like api://<your api> [Parameter(Mandatory, ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(Mandatory, ParameterSetName = 'DeviceCodeFlow')] [Parameter(Mandatory, ParameterSetName = 'WAMFlow')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithCertificate')] [Parameter(Mandatory, ParameterSetName = 'OnBehalfFlowWithSecret')] [string[]] $Permissions, #User.read, Directory.Read ... [Parameter(ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(ParameterSetName = 'WAMFlow')] [Parameter(ParameterSetName = 'DeviceCodeFlow')] [string[]]$ExtraScopesToConsent, [switch]$WithDebugLogging, [Parameter(ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(ParameterSetName = 'DeviceCodeFlow')] [switch]$WithLocalCaching, [ValidateScript({ if(-Not ([System.IO.Directory]::Exists($_)) ){ throw "Folder does not exist" } return $true })] [Parameter(ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(ParameterSetName = 'DeviceCodeFlow')] [string]$TokenSerializationPath = $null, [Parameter(ParameterSetName = 'PublicAuthorizationCodeFlow')] [Parameter(ParameterSetName = 'DeviceCodeFlow')] [switch]$WithUnprotectedTokenSerialization ) Write-Verbose "[$((Get-Date).TimeofDay)] Starting $($myinvocation.mycommand)" if ($Resource -eq 'Custom') { if ($null -eq $CustomResource) { Throw "CustomScope parameter should not be null when you're using Custom audience" } } switch ($Resource) { 'Keyvault' { $ScopesUri = 'https://vault.azure.net'; break } 'ARM' { $ScopesUri = 'https://management.azure.com'; break } 'GraphAPI' { $ScopesUri = 'https://graph.microsoft.com'; break } 'Storage' { $ScopesUri = 'https://storage.azure.com'; break } 'Monitor' { $ScopesUri = 'https://monitor.azure.com'; break } 'LogAnalytics' { $ScopesUri = 'https://api.loganalytics.io'; break } 'PostGreSql' { $ScopesUri = 'https://ossrdbms-aad.database.windows.net'; break } default { $ScopesUri = $CustomResource } } If ($PSBoundParameters[@('ClientCredentialFlowWithSecret', 'ClientCredentialFlowWithCertificate', 'SystemManagedIdentity', 'UserManagedIdentity', 'FederatedCredentialFlowWithAssertion')]) { # In case a user provide api://fsdfsdf/ with a / at the end if ($CustomResource -match '\\$') { [string[]]$scopes = '{0}{1}' -f $ScopesUri, '.default' } else { [string[]]$scopes = '{0}/{1}' -f $ScopesUri, '.default' } } else { # Means user may provide one Resource (Azure limitation) but multiple permissions $TempArray = @() Foreach ($Permission in $Permissions) { if ($CustomResource -match '\\$') { $TempArray += '{0}{1}' -f $ScopesUri, $Permission } else { $TempArray += '{0}/{1}' -f $ScopesUri, $Permission } } [string[]]$scopes = $TempArray } Write-Verbose "[$((Get-Date).TimeofDay)] Scope requested are $Scopes" #Reset main variables [Microsoft.Identity.Client.AuthenticationResult] $AuthenticationResult = $T = $WAMToken = $null # Validate first if local cache is requested and if not we will use the memory cache if ($WithLocalCaching) { Write-Verbose "[$((Get-Date).TimeofDay)] Use or build MSAL local cache on the filesystem" if([string]::IsNullOrEmpty($TokenSerializationPath)){ Write-Verbose "[$((Get-Date).TimeofDay)] Set default token serialization path to current directory" $TokenSerializationPath = $PWD } #Loacal cache $Config = @{ KeyChainServiceName = 'msal_service' KeyChainAccountName = 'msal_account' LinuxKeyRingSchema = 'com.usts.devtools.tokencache' LinuxKeyRingCollection = 'MsalCacheStorage.LinuxKeyRingDefaultCollection' LinuxKeyRingLabel = 'MSAL token cache' } $storagePropertiesBuilder = [Microsoft.Identity.Client.Extensions.Msal.StorageCreationPropertiesBuilder]::new('tokens_cache.dat', $TokenSerializationPath, $clientId) if($WithUnprotectedTokenSerialization){ Write-Verbose "[$((Get-Date).TimeofDay)] Generated token saved in plaintext on filesystem" $storagePropertiesBuilder = $storagePropertiesBuilder.WithUnprotectedFile() } else{ $storagePropertiesBuilder = $storagePropertiesBuilder.WithMacKeyChain($config.KeyChainServiceName, $config.KeyChainAccountName) $storagePropertiesBuilder = $storagePropertiesBuilder.WithLinuxKeyring( $Config.LinuxKeyRingSchema, $Config.LinuxKeyRingCollection, $Config.LinuxKeyRingLabel, [Collections.Generic.KeyValuePair`2[String, string]]::New('Version', '1'), [Collections.Generic.KeyValuePair`2[String, String]]::New('module', 'PSMSALNet') ) } $storagePropertiesProps = $storagePropertiesBuilder.Build() } else { #Memory cache Write-Verbose "[$((Get-Date).TimeofDay)] Use MSAL memory cache" if (-not (Get-Variable -Name PublicClientApplications -ErrorAction SilentlyContinue)) { [System.Collections.Generic.List[Microsoft.Identity.Client.IPublicClientApplication]] $script:PublicClientApplications = New-Object 'System.Collections.Generic.List[Microsoft.Identity.Client.IPublicClientApplication]' } } If ($PSBoundParameters[@('ClientCredentialFlowWithSecret', 'ClientCredentialFlowWithCertificate', 'OnBehalfFlowWithSecret', 'OnBehalfFlowWithCertificate', 'FederatedCredentialFlowWithAssertion')]) { Write-Verbose "[$((Get-Date).TimeofDay)] Confidential application selected" $ClientApplicationBuilder = [Microsoft.Identity.Client.ConfidentialClientApplicationBuilder]::Create($ClientId) #Common Authority can't be used with this flow $ClientApplicationBuilder.WithAuthority($AzureCloudInstance, $TenantId) | Out-Null if ($WithoutCaching) { Write-Verbose "[$((Get-Date).TimeofDay)] Caching disabled with client credential flow" $ClientApplicationBuilder.WithCacheOptions($false) | Out-Null } else { $ClientApplicationBuilder.WithCacheOptions($true) | Out-Null } switch -regex ($PSBoundParameters.Keys) { 'ClientCredentialFlowWithSecret|OnBehalfFlowWithSecret' { $ClientApplicationBuilder.WithClientSecret($ClientSecret) | Out-Null break } 'ClientCredentialFlowWithCertificate|OnBehalfFlowWithCertificate' { $ClientApplicationBuilder.WithCertificate($ClientCertificate) | Out-Null break } 'FederatedCredentialFlowWithAssertion' { #https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/web-apps-apis/confidential-client-assertions $ClientApplicationBuilder.WithClientAssertion($UserAssertion) | Out-Null break } default { throw 'Should not go there' } } } elseif ($PSBoundParameters['SystemManagedIdentity']) { Write-Verbose "[$((Get-Date).TimeofDay)] System Managed identity selected" $ClientApplicationBuilder = [Microsoft.Identity.Client.ManagedIdentityApplicationBuilder]::Create([Microsoft.Identity.Client.AppConfig.ManagedIdentityId]::SystemAssigned) #TODO: Remove this part once ARC for Linux will be fixed by MSAL.Net #Test ARC try { #Test if Arc Agent is running $null = Get-Process himds -ErrorAction stop if ($IsLinux) { Write-Verbose "[$((Get-Date).TimeofDay)] Running with Arc For Linux" $Headers = $null $Headers = @{ 'Metadata' = 'true' } switch ($Resource) { 'Keyvault' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://vault.azure.net'); break } 'ARM' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://management.azure.com'); break } 'GraphAPI' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://graph.microsoft.com'); break } 'Storage' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://storage.azure.com'); break } 'Monitor' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://monitor.azure.com'); break } 'LogAnalytics' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://api.loganalytics.io'); break } 'PostGreSql' { $EncodedURI = [System.Web.HttpUtility]::UrlEncode('https://ossrdbms-aad.database.windows.net'); break } default { $EncodedURI = [System.Web.HttpUtility]::UrlEncode($CustomResource) } } # Keep the generated token in a local cache. AAD does not like when you hammer the service from ARC servers. $MSIEndpoint = 'http://localhost:40342/metadata/identity/oauth2/token?api-version=2020-06-01' $AudienceURI = '{0}&resource={1}' -f $MSIEndpoint, $EncodedURI Write-Verbose "Using uri: $AudienceURI" [string] $ARCTokensPath = '/var/opt/azcmagent/tokens' #Require read access to /var/opt/azcmagent/tokens # Thi is where keys are generated $ARCTokensPath = Join-Path $ARCTokensPath -ChildPath '*.key' #Why 4 ? Why not for ($i = 0; $i -lt 4; $i++) { $Headers.Add('Authorization', $("Basic $(Get-Content -Path $ARCTokensPath -ErrorAction SilentlyContinue)")) try { $response = Invoke-RestMethod -Uri $AudienceURI -Headers $Headers -ErrorAction stop } catch { Write-Debug 'Generate local key that will be provided to Entra' } #This is when the agent generate a new key stored in $ARCTokensPath if ($response) { return $response.access_token } $Headers.Remove('Authorization') $i++ Write-Verbose 'wait 1 second...' Start-Sleep 1 } Throw('No access token received') } } catch { $null } #################### end part to remove } elseif ($PSBoundParameters['UserManagedIdentity']) { Write-Verbose "[$((Get-Date).TimeofDay)] User Managed identity selected" $ClientApplicationBuilder = [Microsoft.Identity.Client.ManagedIdentityApplicationBuilder]::Create([Microsoft.Identity.Client.AppConfig.ManagedIdentityId]::WithUserAssignedClientId($ClientId)) } else { # used by authorizationCode & Device code Write-Verbose "[$((Get-Date).TimeofDay)] Public application selected" $ClientApplicationBuilder = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($ClientId) if ($PSBoundParameters['TenantId']) { Write-Verbose "[$((Get-Date).TimeofDay)] Single tenant app used" $ClientApplicationBuilder.WithAuthority($AzureCloudInstance, $TenantId) | Out-Null } else { Write-Verbose "[$((Get-Date).TimeofDay)] Multi tenant app used" $ClientApplicationBuilder.WithAuthority($AzureCloudInstance, 'common') | Out-Null } if ($WAMFlow) { Write-Verbose "[$((Get-Date).TimeofDay)] WAM flow selected" #Never succeed to make WAM working straight on pwsh. The method WithBroker does not work. if ($TenantId) { Write-Verbose "[$((Get-Date).TimeofDay)] Single tenant app used" if ($extraScopesToConsent) { $WAMToken = Get-WAMToken -ClientId $ClientId -RedirectUri $RedirectUri -TenantId $TenantId -AzureCloudInstance $AzureCloudInstance -Scopes $Scopes -extraScopesToConsent $ExtraScopesToConsent } else { $WAMToken = Get-WAMToken -ClientId $ClientId -RedirectUri $RedirectUri -TenantId $TenantId -AzureCloudInstance $AzureCloudInstance -Scopes $Scopes } } else { # Will use the common endpoint Write-Verbose "[$((Get-Date).TimeofDay)] Multi tenant app used" if ($extraScopesToConsent) { $WAMToken = Get-WAMToken -ClientId $ClientId -RedirectUri $RedirectUri -AzureCloudInstance $AzureCloudInstance -Scopes $Scopes -extraScopesToConsent $ExtraScopesToConsent } else { $WAMToken = Get-WAMToken -ClientId $ClientId -RedirectUri $RedirectUri -AzureCloudInstance $AzureCloudInstance -Scopes $Scopes } } return $WAMToken #https://devblogs.microsoft.com/identity/improved-windows-broker-support-with-msal-net/ } else { $ClientApplicationBuilder.WithRedirectUri($RedirectUri) | Out-Null } } if ($WithDebugLogging) { Write-Verbose "[$((Get-Date).TimeofDay)] Debug logging selected" #https://learn.microsoft.com/en-us/entra/msal/dotnet/advanced/exceptions/msal-logging#logging-levels $ClientApplicationBuilder.WithLogging([PSMSALNetHelper.Mylogger]::new(), 'piilogging') | Out-Null } $ClientApplication = $ClientApplicationBuilder.Build() if ($WithLocalCaching) { # Cache helper Write-Verbose "[$((Get-Date).TimeofDay)] Try to load the local cache in MSAL memory" $CacheHelper = [Microsoft.Identity.Client.Extensions.Msal.MsalCacheHelper]::CreateAsync($storagePropertiesProps).Result $CacheHelper.RegisterCache($ClientApplication.UserTokenCache) } If ($PSBoundParameters[@('ClientCredentialFlowWithSecret', 'ClientCredentialFlowWithCertificate', 'FederatedCredentialFlowWithAssertion')]) { #Client credential flow no user cache so no silent $AquireTokenParameters = $ClientApplication.AcquireTokenForClient($Scopes) } elseif ($PSBoundParameters[@('SystemManagedIdentity', 'UserManagedIdentity')]) { $AquireTokenParameters = $ClientApplication.AcquireTokenForManagedIdentity($Scopes) } elseif ($PSBoundParameters[@('OnBehalfFlowWithCertificate', 'OnBehalfFlowWithSecret')]) { $AquireTokenParameters = $ClientApplication.AcquireTokenOnBehalfOf($Scopes, [Microsoft.Identity.Client.UserAssertion]::new($UserAssertion)) } else { try { $T = $PublicClientApplications | Where-Object { $_.ClientId -eq $ClientId -and $_.AppConfig.RedirectUri -eq $RedirectUri } | Select-Object -Last 1 if($WithLocalCaching -AND ($null -ne $ClientApplication)){ Write-Verbose "[$((Get-Date).TimeofDay)] Let's use the local filesystem cache" } elseif ($null -eq $T) { Write-Verbose "[$((Get-Date).TimeofDay)] No account found in memory cache, let's add it for the next run" $PublicClientApplications.Add($ClientApplication) } else { Write-Verbose "[$((Get-Date).TimeofDay)] Account found in memory cache, let's use it" $ClientApplication = $T } [Microsoft.Identity.Client.IAccount]$Account = $ClientApplication.GetAccountsAsync().GetAwaiter().GetResult() | Select-Object -First 1 if ($null -eq $Account) { throw } else { $Account | Out-Null } Write-Verbose "[$((Get-Date).TimeofDay)] Acquire token silently" $AquireTokenParameters = $ClientApplication.AcquireTokenSilent($Scopes, $Account) } catch { if ($DeviceCodeFlow) { Write-Verbose "[$((Get-Date).TimeofDay)] Acquire token with device code" $AquireTokenParameters = $ClientApplication.AcquireTokenWithDeviceCode($Scopes, [DeviceCodeHelper]::GetDeviceCodeResultCallback()) } else { Write-Verbose "[$((Get-Date).TimeofDay)] Acquire token interactively" $AquireTokenParameters = $ClientApplication.AcquireTokenInteractive($Scopes) if ($extraScopesToConsent) { $AquireTokenParameters.WithExtraScopesToConsent($extraScopesToConsent) | Out-Null } } } } # Do the async call to get a token $Timeout = New-TimeSpan -Minutes 2 $tokenSource = New-Object System.Threading.CancellationTokenSource try { #$AuthenticationResult = $AquireTokenParameters.ExecuteAsync().GetAwaiter().GetResult() $taskAuthenticationResult = $AquireTokenParameters.ExecuteAsync($tokenSource.Token) try { $endTime = [datetime]::Now.Add($Timeout) while (!$taskAuthenticationResult.IsCompleted) { if ($Timeout -eq [timespan]::Zero -or [datetime]::Now -lt $endTime) { Start-Sleep -Seconds 1 } else { $tokenSource.Cancel() $taskAuthenticationResult.Wait() #try { $taskAuthenticationResult.Wait() } #catch { } Write-Error -Exception (New-Object System.TimeoutException) -Category ([System.Management.Automation.ErrorCategory]::OperationTimeout) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'GetMsalTokenFailureOperationTimeout' -TargetObject $AquireTokenParameters -ErrorAction Stop } } } finally { if (!$taskAuthenticationResult.IsCompleted) { Write-Warning 'Canceling Token Acquisition for Application with ClientId [{0}]' -f $ClientApplication.ClientId $tokenSource.Cancel() } $tokenSource.Dispose() } ## Parse task results if ($taskAuthenticationResult.IsFaulted) { Write-Error -Exception $taskAuthenticationResult.Exception -Category ([System.Management.Automation.ErrorCategory]::AuthenticationError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'GetMsalTokenFailureAuthenticationError' -TargetObject $AquireTokenParameters -ErrorAction Stop } if ($taskAuthenticationResult.IsCanceled) { Write-Error -Exception (New-Object System.Threading.Tasks.TaskCanceledException $taskAuthenticationResult) -Category ([System.Management.Automation.ErrorCategory]::OperationStopped) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'GetMsalTokenFailureOperationStopped' -TargetObject $AquireTokenParameters -ErrorAction Stop } else { $AuthenticationResult = $taskAuthenticationResult.Result } } catch { Write-Error -Exception ($_.Exception) -Category ([System.Management.Automation.ErrorCategory]::AuthenticationError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'GetMsalTokenFailureAuthenticationError' -TargetObject $AquireTokenParameters -ErrorAction Stop } Write-Verbose "[$((Get-Date).TimeofDay)] Ending $($myinvocation.mycommand)" # Return access token + Id token $AuthenticationResult } #EndRegion '.\Public\Get-EntraToken.ps1' 756 #Region '.\Public\Get-KVCertificateWithPrivateKey.ps1' -1 function Get-KVCertificateWithPrivateKey { <# .SYNOPSIS This is a function to download certificate information from Azure KeyVault and export the private key as well. .DESCRIPTION This is a function to download certificate information from Azure KeyVault and export the private key as well. .EXAMPLE TODO: Write examples .PARAMETER KeyVaultCertificatePath The KeyVaultCertificatePath parameter is the path of the Keyvault certificate. .PARAMETER AccessToken The AccessToken parameter is the JWT you have to provide to do the action. .PARAMETER APIVersion The APIVersion parameter is the version of the Keyvault API. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$KeyVaultCertificatePath, #https://ubuntukv415745.vault.azure.net/certificates/test/5d69153b75214245ab72fa21b9c06bfb [Parameter(Mandatory)] [string]$AccessToken, [string]$APIVersion = '7.3' ) # Force TLS 1.2. Write-Verbose "[$((Get-Date).TimeofDay)] Starting $($myinvocation.mycommand)" Write-Verbose "[$((Get-Date).TimeofDay)] Get-KVCertificateWithPrivateKey - Force TLS 1.2" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 if ($AccessToken -notlike 'Bearer*') { $AccessToken = "Bearer $($AccessToken)" } $Headers = @{ 'Content-Type' = 'application/json' 'Authorization' = $AccessToken } $splat = @{ 'KeyVaultCertificatePath' = $KeyVaultCertificatePath 'AccessToken' = $AccessToken 'APIVersion' = $APIVersion } $CertInfo = Get-KVCertificateWithPublicKey @splat if ($null -eq $CertInfo.sid) { throw 'Unable to find private key information' } #Now we have certificate information let's find private key info Invoke-RestMethod -Uri "$($CertInfo.sid)?api-version=$($APIVersion)" -Headers $Headers } #EndRegion '.\Public\Get-KVCertificateWithPrivateKey.ps1' 63 #Region '.\Public\Get-KVCertificateWithPublicKey.ps1' -1 function Get-KVCertificateWithPublicKey { <# .SYNOPSIS This is a function to download certificate information from Azure KeyVault. .DESCRIPTION This is a function to download certificate information from Azure KeyVault. .EXAMPLE TODO: Write examples .PARAMETER KeyVaultCertificatePath The KeyVaultCertificatePath parameter is the path of the Keyvault certificate. .PARAMETER AccessToken The AccessToken parameter is the JWT you have to provide to do the action. .PARAMETER APIVersion The APIVersion parameter is the version of the Keyvault API. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$KeyVaultCertificatePath, #https://ubuntukv415745.vault.azure.net/certificates/test/5d69153b75214245ab72fa21b9c06bfb [Parameter(Mandatory)] [string]$AccessToken, [string]$APIVersion = '7.3' ) # Force TLS 1.2. Write-Verbose "[$((Get-Date).TimeofDay)] Starting $($myinvocation.mycommand)" Write-Verbose "[$((Get-Date).TimeofDay)] Get-KVCertificateWithPublicKey - Force TLS 1.2" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 if ($AccessToken -notlike 'Bearer*') { $AccessToken = "Bearer $($AccessToken)" } $Headers = @{ 'Content-Type' = 'application/json' 'Authorization' = $AccessToken } $CertURL = "$($KeyVaultCertificatePath)?api-version=$($APIVersion)" #$certURL = "https://ubuntukv415745.vault.azure.net/certificates/test/5d69153b75214245ab72fa21b9c06bfb?api-version=$APIVersion" Invoke-RestMethod -Uri $certURL -Headers $Headers } #EndRegion '.\Public\Get-KVCertificateWithPublicKey.ps1' 52 |