MSGraphPSEssentials.psm1
#Requires -Version 5.1 using namespace System using namespace System.Management.Automation.Host using namespace System.Runtime.InteropServices using namespace System.Security.Cryptography using namespace System.Security.Cryptography.X509Certificates <# Release Notes for v0.6.0 (2022-01-12): - Updated New-MSGraphRequest to no longer be a recursive function in order to gracefully avoid call depth overflow, in the event that there are too many nextLink's (i.e., too may users to return in large orgs). It is still recommended to use the $top OData query option to set a larger page size to reduce the number of nextLink's required to fetch all results. This change just avoids any issues with call depth overflow, regardless of proactive/strategic $top usage. - Updated New-MSGraphAccessToken so that it includes a new property in the output - 'issued_at'. This new property will be used by Get-AccessTokenExpiration. - Updated Get-AccessTokenExpiration so that it does its check using the 'issued_at' and 'expires_in' properties from objects output by New-MSGraphAccessToken. This is being done because during a Microsoft Identity Platform webinar, I learned that Microsoft will soon begin encrypting all access tokens and that they should never be looked at by programs, including to determine when they'll expire. With the shimmmed-in 'issue_at' property, combined with the already-included expires_in property that comes with the access_token, we can still accomplish the same goal. #> function New-MSGraphAccessToken { [CmdletBinding( DefaultParameterSetName = 'DeviceCode_Endpoint' )] param ( [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_Certificate')] [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_CertificateStorePath')] [Parameter(Mandatory, ParameterSetName = 'DeviceCode_TenantId')] [Parameter(Mandatory, ParameterSetName = 'RefreshToken_TenantId')] [Parameter(Mandatory, ParameterSetName = 'RefreshTokenCredential_TenantId')] [string]$TenantId, # Guid / FQDN [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_Certificate')] [Parameter(Mandatory, ParameterSetName = 'ClientCredentials_CertificateStorePath')] [Parameter(Mandatory, ParameterSetName = 'DeviceCode_TenantId')] [Parameter(Mandatory, ParameterSetName = 'DeviceCode_Endpoint')] [Parameter(Mandatory, ParameterSetName = 'RefreshToken_TenantId')] [Parameter(Mandatory, ParameterSetName = 'RefreshToken_Endpoint')] [Guid]$ApplicationId, [Parameter( Mandatory, ParameterSetName = 'ClientCredentials_Certificate', HelpMessage = 'E.g. Use $Certificate, where `$Certificate = Get-ChildItem cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317' )] [X509Certificate2]$Certificate, [Parameter( Mandatory, ParameterSetName = 'ClientCredentials_CertificateStorePath', HelpMessage = 'E.g. cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317; E.g. cert:\LocalMachine\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317' )] [ValidateScript( { if (Test-Path -Path $_) { $true } else { throw "An example proper path would be 'cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'." } } )] [string]$CertificateStorePath, [Parameter(ParameterSetName = 'ClientCredentials_Certificate')] [Parameter(ParameterSetName = 'ClientCredentials_CertificateStorePath')] [switch]$ExoEwsAppOnlyScope, [Parameter(ParameterSetName = 'ClientCredentials_Certificate')] [Parameter(ParameterSetName = 'ClientCredentials_CertificateStorePath')] [ValidateRange(1, 10)] [int16]$JWTExpMinutes = 2, [Parameter(ParameterSetName = 'DeviceCode_Endpoint')] [Parameter(ParameterSetName = 'RefreshToken_Endpoint')] [Parameter(ParameterSetName = 'RefreshTokenCredential_Endpoint')] [ValidateSet('Common', 'Consumers', 'Organizations')] [string]$Endpoint = 'Common', [Parameter(Mandatory, ParameterSetName = 'DeviceCode_TenantId', HelpMessage = 'E.g. Mail.Send, Ews.AccessAsUser.All')] [Parameter(Mandatory, ParameterSetName = 'DeviceCode_Endpoint', HelpMessage = 'E.g. Mail.Send, Ews.AccessAsUser.All')] [Parameter(ParameterSetName = 'RefreshToken_TenantId')] [Parameter(ParameterSetName = 'RefreshToken_Endpoint')] [Parameter(ParameterSetName = 'RefreshTokenCredential_TenantId')] [Parameter(ParameterSetName = 'RefreshTokenCredential_Endpoint')] [string[]]$Scopes, [Parameter(Mandatory, ParameterSetName = 'RefreshToken_TenantId')] [Parameter(Mandatory, ParameterSetName = 'RefreshToken_Endpoint')] [ValidateScript( { if ($_.token_type -eq 'Bearer' -and $_.refresh_token) { $true } else { throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken -Scopes offline_access...' } } )] [Object]$RefreshToken, [Parameter(Mandatory, ParameterSetName = 'RefreshTokenCredential_TenantId')] [Parameter(Mandatory, ParameterSetName = 'RefreshTokenCredential_Endpoint')] [PSCredential]$RefreshTokenCredential ) #region Initialization if ($PSCmdlet.ParameterSetName -eq 'ClientCredentials_CertificateStorePath') { try { $Script:Certificate = Get-ChildItem -Path $CertificateStorePath -ErrorAction Stop } catch { throw } } elseif ($PSCmdlet.ParameterSetName -eq 'ClientCredentials_Certificate') { $Script:Certificate = $Certificate } if ($PSCmdlet.ParameterSetName -like 'ClientCredentials_*') { if (-not (Test-SigningCertificate -Certificate $Script:Certificate)) { throw "The supplied certificate must use the provider 'Microsoft Enhanced RSA and AES Cryptographic Provider', " + 'and the SHA-256 hashing algorithm. ' + 'For best luck, use a certificate generated using New-SelfSignedMSGraphApplicationCertificate.' } } if ($PSCmdlet.ParameterSetName -like '*_TenantId') { $Script:Endpoint = $TenantId } else { $Script:Endpoint = $Endpoint } if ($PSCmdlet.ParameterSetName -like 'RefreshTokenCredential_*') { try { $Script:ApplicationId = [Guid]$RefreshTokenCredential.UserName $Script:RefreshToken = ConvertFrom-Json (ConvertFrom-SecureStringToPlainText $RefreshTokenCredential.Password) } catch { 'Failed to validate refresh token credential object. ' + 'Supply $RefreshTokenObject where $RefreshTokenObject = New-RefreshTokenObject ...' | Write-Warning throw } } elseif ($PSCmdlet.ParameterSetName -like 'RefreshToken_*') { $Script:ApplicationId = $ApplicationId $Script:RefreshToken = $RefreshToken } #endregion Initialization #region Functions function New-AppOnlyAccessToken ($TenantId, $ApplicationId, $Certificate, $JWTExpMinutes) { try { $NowUTC = [datetime]::UtcNow $EncodedHeader = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes( (ConvertTo-Json -InputObject ( @{ alg = 'RS256' typ = 'JWT' x5t = ConvertTo-Base64Url -String ([Convert]::ToBase64String($Certificate.GetCertHash())) } ) ) ) ) $EncodedPayload = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes( (ConvertTo-Json -InputObject ( @{ aud = "https://login.microsoftonline.com/$TenantId/oauth2/token" exp = (Get-Date $NowUTC.AddMinutes($JWTExpMinutes) -UFormat '%s') -replace '\..*' iss = $ApplicationId.Guid jti = [Guid]::NewGuid() nbf = (Get-Date $NowUTC -UFormat '%s') -replace '\..*' sub = $ApplicationId.Guid } ) ) ) ) $JWT = (ConvertTo-Base64Url -String $EncodedHeader, $EncodedPayload) -join '.' $Signature = ConvertTo-Base64Url -String ( [Convert]::ToBase64String( $Script:Certificate.PrivateKey.SignData( [Text.Encoding]::UTF8.GetBytes($JWT), [HashAlgorithmName]::SHA256, [RSASignaturePadding]::Pkcs1 ) ) ) $JWT = $JWT + '.' + $Signature $trBody = @{ client_id = $ApplicationId client_assertion = $JWT client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" scope = "$(if ($ExoEwsAppOnlyScope) {'https://outlook.office365.com' } else { 'https://graph.microsoft.com' })/.default" grant_type = "client_credentials" } $trParams = @{ Method = 'POST' Uri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" Body = $trBody Headers = @{ Authorization = "Bearer $($JWT)" } ContentType = 'application/x-www-form-urlencoded' UserAgent = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)" ErrorAction = 'Stop' } $trResponse = Invoke-RestMethod @trParams # Output the token request response: $trResponse } catch { throw } } function New-DeviceCodeAccessToken ($Endpoint, $ApplicationId, $Scopes) { try { $dcrBody = @( "client_id=$($ApplicationId)", "scope=$($Scopes -join ' ')" ) -join '&' $dcrParams = @{ Method = 'POST' Uri = "https://login.microsoftonline.com/$($Endpoint)/oauth2/v2.0/devicecode" Body = $dcrBody ContentType = 'application/x-www-form-urlencoded' UserAgent = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)" ErrorAction = 'Stop' } $dcrResponse = Invoke-RestMethod @dcrParams $dtNow = [datetime]::Now $sw1 = [Diagnostics.Stopwatch]::StartNew() $dcExpiration = "$($dtNow.AddSeconds($dcrResponse.expires_in).ToString('yyyy-MM-dd hh:mm:ss tt'))" $trBody = @( "grant_type=urn:ietf:params:oauth:grant-type:device_code", "client_id=$($ApplicationId)", "device_code=$($dcrResponse.device_code)" ) -join '&' # Wait for user to enter code before starting to poll token endpoint: switch ( $host.UI.PromptForChoice( "Authorization started (expires at $($dcExpiration))", "$($dcrResponse.message)", [ChoiceDescription]('&Done'), 0 ) ) { 0 { <##> } } if ($sw1.Elapsed.Minutes -lt 15) { $sw2 = [Diagnostics.Stopwatch]::StartNew() $successfulResponse = $false $pollCount = 0 do { if ($sw2.Elapsed.Seconds -ge $dcrResponse.interval) { $sw2.Restart() $pollCount++ try { $trParams = @{ Method = 'POST' Uri = "https://login.microsoftonline.com/$($Endpoint)/oauth2/v2.0/token" Body = $trBody ContentType = 'application/x-www-form-urlencoded' UserAgent = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)" ErrorAction = 'Stop' } $trResponse = Invoke-RestMethod @trParams $successfulResponse = $true } catch { if ($_.ErrorDetails.Message) { $badResponse = ConvertFrom-Json -InputObject $_.ErrorDetails.Message if ($badResponse.error -eq 'authorization_pending') { if ($pollCount -eq 1) { "The user hasn't finished authenticating, but hasn't canceled the flow (error: authorization_pending). " + "Continuing to poll the token endpoint at the requested interval ($($dcrResponse.interval) seconds)." | Write-Warning } } elseif ($badResponse.error -match '^(authorization_declined)|(bad_verification_code)|(expired_token)$') { # https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#expected-errors throw "Authorization failed due to foreseeable error: $($badResponse.error)." } elseif ($_.errorDetails.message -match '(AADSTS7000218)') { "Authorization failed due to 'invalid_client' (AADSTS7000218). " + 'Ensure the Application is enabled for public client flows in Azure AD. ' + 'If this is app is intended for app-only/unattended use, you should instead use the -Certificate/-CertificateStorePath parameters.' | Write-Warning throw } else { Write-Warning 'Authorization failed due to an unexpected error.' throw $badResponse.error_description } } else { Write-Warning 'An error was encountered with the Invoke-RestMethod command. Authorization request did not complete.' throw } } } if (-not $successfulResponse) { Start-Sleep -Seconds 1 } } while ($sw1.Elapsed.Minutes -lt 15 -and -not $successfulResponse) # Output the token request response: $trResponse } else { throw "Authorization request expired at $($dcExpiration), please try again." } } catch { if ($_.errorDetails.message -match '(AADSTS50059)') { "Device code request failed due to 'invalid_request' (AADSTS50059). " + 'This appears to be a single-tenant app. Retry the command, supplying -TenantId <tenant Domain|Guid>.' | Write-Warning throw } elseif ($_.errorDetails.message -match '(AADSTS70011)') { "Device code request failed due to 'invalid_scope' (AADSTS70011), which typically means the requested scope(s) do not work with the specified endpoint ('Common' by default). " + 'Retry the command with either -Endpoint Organizations or -TenantId <tenant Domain|Guid>.' | Write-Warning throw } else { throw } } } function Get-RefreshedAcessToken ($Endpoint, $ApplicationId, $RefreshToken, $Scopes) { try { $trBody = @( "client_id=$($ApplicationId)", 'grant_type=refresh_token', "refresh_token=$($RefreshToken.refresh_token)" ) if ($Scopes) { $trBody += "scope=$($Scopes -join ' ')" } $trBody = $trBody -join '&' $trParams = @{ Method = 'POST' Uri = "https://login.microsoftonline.com/$($Endpoint)/oauth2/v2.0/token" Body = $trBody ContentType = 'application/x-www-form-urlencoded' UserAgent = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)" ErrorAction = 'Stop' } $trResponse = Invoke-RestMethod @trParams # Output the token request response: $trResponse } catch { throw } } #endregion Functions #region Main try { $TokenIssuedAt = [datetime]::Now $TokenObject = switch -Wildcard ($PSCmdlet.ParameterSetName) { 'ClientCredentials_*' { New-AppOnlyAccessToken $TenantId $ApplicationId $Script:Certificate $JWTExpMinutes } 'DeviceCode_*' { New-DeviceCodeAccessToken $Script:Endpoint $ApplicationId $Scopes } 'RefreshToken*' { Get-RefreshedAcessToken $Script:Endpoint $Script:ApplicationId $Script:RefreshToken $Scopes } } $TokenObject | Add-Member -NotePropertyName issued_at -NotePropertyValue $TokenIssuedAt -PassThru } catch { if ($_.errorDetails.message -match '(AADSTS50194)') { "Application $($ApplicationId) appears to be a single-tenant application. " + 'Please supply either -Endpoint:Organizations or -TenantId:<Tenant Id/Guid>' | Write-Warning throw "$((ConvertFrom-Json $_.errorDetails.message).error_description)" } else { throw } } #endregion Main } function New-MSGraphRequest { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Request, [Parameter(Mandatory)] [ValidateScript( { if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else { throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken ...' } } )] [Object]$AccessToken, [Alias('API', 'Version', 'Endpoint')] [ValidateSet('v1.0', 'beta')] [string]$ApiVersion = 'v1.0', [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')] [string]$Method = 'GET', [string]$Body, [ValidateSet('Ignore', 'Warn', 'Inquire', 'Continue', 'SilentlyContinue')] [string]$nextLinkAction = 'Warn' ) try { function _sendRequest ([hashtable]$requestParams) { Invoke-RestMethod @requestParams } $_initialRequestParams = @{ Headers = @{ Authorization = "Bearer $($AccessToken.access_token)" } Uri = "https://graph.microsoft.com/$($ApiVersion)/$($Request)" Method = $Method ContentType = 'application/json' UserAgent = "MSGraphPSEssentials/$($PSCmdlet.MyInvocation.MyCommand.Module.Version)" ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { if ($Method -notmatch '(POST)|(PATCH)') { throw "Body is not allowed when the method is $($Method), only POST or PATCH." } else { $_initialRequestParams['Body'] = $Body } } $_initialResponse = _sendRequest -requestParams $_initialRequestParams $_initialResponse if ($_initialResponse.'@odata.nextLink') { $Script:Continue = $true switch ($nextLinkAction) { Ignore { $Script:Continue = $false } Warn { Write-Warning -Message "There are more results available. Next page: $($_initialResponse.'@odata.nextLink')" $Script:Continue = $false } 'Continue' { Write-Information -MessageData 'There are more results available. Getting the next page(s)...' } Inquire { switch ( $host.UI.PromptForChoice( 'There are more results available (i.e. response included @odata.nextLink).', 'Get more results?', [ChoiceDescription[]]@('&Yes', '&No'), 1 ) ) { 0 { <# Will prompt for choice again if the next response includes another @odata.nextLink.#> } 1 { $Script:Continue = $false } } } default { <# SilentlyContinue (legacySupport) #> } } if ($Script:Continue) { $_lastResponse = $null do { $_nextLinkRequestParams = $_initialRequestParams.Clone() $_nextLinkRequestParams.Uri = if ($null -ne $_lastResponse) { $_lastResponse.'@odata.nextLink' } else { $_initialResponse.'@odata.nextLink' } $_thisResponse = _sendRequest -requestParams $_nextLinkRequestParams $_thisResponse $_lastResponse = $_thisResponse } while ($null -ne $_lastResponse.'@odata.nextLink') } } } catch { if ($_.Exception.Response.StatusCode.value__ -eq 429) { "The request was throttled by Microsoft Graph/Azure AD. " + "Please wait $($_.Exception.Response.Headers['Retry-After']) seconds before retrying the request, per the Retry-After response header (see `$Error[0].Exception.Response.Headers['Retry-After'])." | Write-Warning } throw } } function New-SelfSignedMSGraphApplicationCertificate { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Subject, [string]$FriendlyName, [ValidateScript( { if (Test-Path -Path $_) { $true } else { throw "An example proper location would be 'cert:\CurrentUser\My'." } } )] [string]$CertStoreLocation = 'cert:\CurrentUser\My', [datetime]$NotAfter = [datetime]::Now.AddDays(90), [ValidateSet('Signature', 'KeyExchange')] [string]$KeySpec = 'Signature' ) $NewCertParams = @{ Subject = $Subject FriendlyName = $FriendlyName CertStoreLocation = $CertStoreLocation NotAfter = $NotAfter KeySpec = $KeySpec Provider = 'Microsoft Enhanced RSA and AES Cryptographic Provider' HashAlgorithm = 'SHA256' ErrorAction = 'Stop' } try { if ($PSVersionTable.PSEdition -eq 'Desktop') { New-SelfSignedCertificate @NewCertParams } else { # PowerShell Core's PKI module has an issue with allowing the private key to be exportable: # https://github.com/PowerShell/PowerShell/issues/12081 try { #Pre-import the PKI module to make the WinPSCompatSession available to Invoke-Command: Import-Module -Name PKI -UseWindowsPowerShell -WarningAction:SilentlyContinue $WinPSCompatSession = Get-PSSession -Name WinPSCompatSession -ErrorAction:Stop if ($WinPSCompatSession) { Invoke-Command -Session $WinPSCompatSession -ScriptBlock { $Global:tmpCertificate = New-SelfSignedCertificate @using:NewCertParams } -ErrorAction:Stop Get-ChildItem -Path "$($CertStoreLocation)\$((Invoke-Command -Session $WinPSCompatSession -ScriptBlock {$tmpCertificate}).Thumbprint)" -ErrorAction:Stop } else { throw } } catch { throw "Failed to use WinPSCompatSession to generate valid self-signed certificate. please use Windows PowerShell 5.1 instead." } } } catch { throw } } function New-MSGraphPoPToken { [CmdletBinding( DefaultParameterSetName = 'Certificate' )] param ( [Parameter(Mandatory)] [Alias('ClientId')] [Guid]$ApplicationObjectId, [Parameter( Mandatory, ParameterSetName = 'Certificate', HelpMessage = 'E.g. Use $Certificate, where `$Certificate = Get-ChildItem cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317' )] [X509Certificate2]$Certificate, [Parameter( Mandatory, ParameterSetName = 'CertificateStorePath', HelpMessage = 'E.g. cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317; E.g. cert:\LocalMachine\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317' )] [ValidateScript( { if (Test-Path -Path $_) { $true } else { throw "An example proper path would be 'cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'." } } )] [string]$CertificateStorePath, [ValidateRange(1, 10)] [int16]$JWTExpMinutes = 2 ) try { if ($PSCmdlet.ParameterSetName -eq 'CertificateStorePath') { $Script:Certificate = Get-ChildItem -Path $CertificateStorePath -ErrorAction Stop } else { $Script:Certificate = $Certificate } if (-not (Test-SigningCertificate -Certificate $Script:Certificate)) { throw "The supplied certificate must use the provider 'Microsoft Enhanced RSA and AES Cryptographic Provider', " + 'and the SHA-256 hashing algorithm. ' + 'For best luck, use a certificate generated using New-SelfSignedMSGraphApplicationCertificate.' } $NowUTC = [datetime]::UtcNow $EncodedHeader = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes( (ConvertTo-Json -InputObject ( @{ alg = 'RS256' typ = 'JWT' x5t = ConvertTo-Base64Url -String ([Convert]::ToBase64String($Script:Certificate.GetCertHash())) } ) ) ) ) $EncodedPayload = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes( (ConvertTo-Json -InputObject ( @{ aud = '00000002-0000-0000-c000-000000000000' iss = $ApplicationObjectId.Guid exp = (Get-Date $NowUTC.AddMinutes($JWTExpMinutes)-UFormat '%s') -replace '\..*' nbf = (Get-Date $NowUTC -UFormat '%s') -replace '\..*' } ) ) ) ) $JWT = (ConvertTo-Base64Url -String $EncodedHeader, $EncodedPayload) -join '.' $Signature = ConvertTo-Base64Url -String ( [Convert]::ToBase64String( $Script:Certificate.PrivateKey.SignData( [Text.Encoding]::UTF8.GetBytes($JWT), [HashAlgorithmName]::SHA256, [RSASignaturePadding]::Pkcs1 ) ) ) $JWT = $JWT + '.' + $Signature # Output the token: $JWT } catch { throw } } function Add-MSGraphApplicationKeyCredential { [CmdletBinding( DefaultParameterSetName = 'Certificate' )] param ( [Parameter(Mandatory)] [Guid]$ApplicationObjectId, [Parameter(Mandatory)] [ValidateScript( { if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else { throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken ...' } } )] [Object]$AccessToken, [Parameter(Mandatory)] [ValidatePattern('^[-\w]+\.[-\w]+\.[-\w]+$')] [string]$PoPToken, [Parameter( Mandatory, ParameterSetName = 'Certificate', HelpMessage = 'E.g. Use $Certificate, where `$Certificate = Get-ChildItem cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317' )] [X509Certificate2]$Certificate, [Parameter( Mandatory, ParameterSetName = 'CertificateStorePath', HelpMessage = 'E.g. cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317; E.g. cert:\LocalMachine\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317' )] [ValidateScript( { if (Test-Path -Path $_) { $true } else { throw "An example proper path would be 'cert:\CurrentUser\My\C3E7F30B9DD50B8B09B9B539BC41F8157642D317'." } } )] [string]$CertificateStorePath ) try { if ($PSCmdlet.ParameterSetName -eq 'CertificateStorePath') { $Script:Certificate = Get-ChildItem -Path $CertificateStorePath -ErrorAction Stop } else { $Script:Certificate = $Certificate } if (-not (Test-SigningCertificate -Certificate $Script:Certificate)) { throw "The supplied certificate must use the provider 'Microsoft Enhanced RSA and AES Cryptographic Provider', " + 'and the SHA-256 hashing algorithm. ' + 'For best luck, use a certificate generated using New-SelfSignedMSGraphApplicationCertificate.' } $Body = @{ proof = $PoPToken keyCredential = @{ type = "AsymmetricX509Cert" usage = "Verify" key = [Convert]::ToBase64String($Script:Certificate.GetRawCertData()) } } $AddKeyParams = @{ AccessToken = $AccessToken Method = 'POST' Request = "applications/$($ApplicationObjectId)/addKey" Body = (ConvertTo-Json $Body) ErrorAction = 'Stop' } New-MSGraphRequest @AddKeyParams } catch { throw } } function Remove-MSGraphApplicationKeyCredential { [CmdletBinding( DefaultParameterSetName = 'CertificateThumbprint' )] param ( [Parameter(Mandatory)] [Guid]$ApplicationObjectId, [Parameter(Mandatory)] [ValidateScript( { if ($_.token_type -eq 'Bearer' -and $_.access_token) { $true } else { throw 'Invalid access token. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken ...' } } )] [Object]$AccessToken, [Parameter(Mandatory)] [ValidatePattern('^[-\w]+\.[-\w]+\.[-\w]+$')] [string]$PoPToken, [Parameter( Mandatory, ParameterSetName = 'CertificateThumbprint' )] [ValidatePattern('^[a-fA-F0-9]{40,40}$')] [string]$CertificateThumbprint, [Parameter( Mandatory, ParameterSetName = 'KeyId' )] [Guid]$KeyId ) try { if ($PSCmdlet.ParameterSetName -eq 'CertificateThumbprint') { $GetApplicationParams = @{ AccessToken = $AccessToken Request = "applications/$($ApplicationObjectId)" ErrorAction = 'Stop' } $Application = New-MSGraphRequest @GetApplicationParams $MatchingKeyCredentials = $Application.keyCredentials | Where-Object { $_.customKeyIdentifier -eq $CertificateThumbprint } if ($MatchingKeyCredentials.Count -gt 1) { "Multiple keyCredentials matching certificate thumbprint $($CertificateThumbprint) were found. " + "List these with the command below, then re-run this command using -KeyId instead of -CertificateThumbprint:`n" + "New-MSGraphRequest -AccessToken <AccessTokenObject> -Request 'applications/$($ApplicationObjectId)' | select -expand keyCredentials" | Write-Warning break } elseif ($MatchingKeyCredentials.Count -lt 1) { throw "No KeyCredential was found with certificate thumbprint $($CertificateThumbprint)." } else { $Script:KeyId = $MatchingKeyCredentials.KeyId } } else { $Script:KeyId = $KeyId.Guid } $Body = @{ keyId = $Script:KeyId proof = $PoPToken } $RemoveKeyParams = @{ AccessToken = $AccessToken Request = "applications/$($ApplicationObjectId)/removeKey" Method = 'POST' Body = (ConvertTo-Json $Body) ErrorAction = 'Stop' } New-MSGraphRequest @RemoveKeyParams } catch { throw } } function Test-SigningCertificate ([X509Certificate2]$Certificate) { if ($PSVersionTable.PSEdition -eq 'Desktop') { $Provider = $Certificate.PrivateKey.CspKeyContainerInfo.ProviderName } else { $Provider = $Certificate.PrivateKey.Key.Provider } if ( $Provider -eq 'Microsoft Enhanced RSA and AES Cryptographic Provider' -and $Certificate.SignatureAlgorithm.FriendlyName -match '(sha256)' ) { $true } else { $false } } function ConvertTo-Base64Url { param ( [ValidatePattern('^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$')] [string[]]$String ) $String -replace '\+', '-' -replace '/', '_' -replace '=' } function ConvertFrom-Base64Url ([string[]]$String) { foreach ($s in $String) { while ($s.Length % 4) { $s += '=' } $s -replace '-', '\+' -replace '_', '/' } } function ConvertFrom-JWTAccessToken { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateScript( { if ($_ -match '^eyJ[-\w]+\.[-\w]+\.[-\w]+$') { $true } else { throw 'Invalid JWT.' } } )] [Object]$JWT ) $Headers, $Payload = ($JWT -split '\.')[0, 1] [PSCustomObject]@{ Headers = ConvertFrom-Json ( [Text.Encoding]::ASCII.GetString( [Convert]::FromBase64String((ConvertFrom-Base64Url $Headers)) ) ) Payload = ConvertFrom-Json( [Text.Encoding]::ASCII.GetString( [Convert]::FromBase64String((ConvertFrom-Base64Url $Payload)) ) ) } } function ConvertFrom-SecureStringToPlainText ([SecureString]$SecureString) { [Marshal]::PtrToStringAuto( [Marshal]::SecureStringToBSTR($SecureString) ) } function New-RefreshTokenCredential { [CmdletBinding()] param ( [Parameter(Mandatory)] [Guid]$ApplicationId, [Parameter(Mandatory)] [ValidateScript( { if ($_.token_type -eq 'Bearer' -and $_.refresh_token) { $true } else { throw 'Invalid token object. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken -Scopes offline_access...' } } )] [Object]$TokenObject ) [PSCredential]::new( $ApplicationId, (ConvertTo-Json $TokenObject | ConvertTo-SecureString -AsPlainText -Force) ) } function Get-AccessTokenExpiration { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateScript( { if (($_.token_type -eq 'Bearer') -and ($_.access_token) -and ($_.expires_in -is [int]) -and ($_.issued_at -is [datetime])) { $true } else { throw 'Invalid token object. Supply $TokenObject where: $TokenObject = New-MSGraphAccessToken...' } } )] [Object]$TokenObject ) try { $Now = [datetime]::Now $Expires = $TokenObject.issued_at.AddSeconds($TokenObject.expires_in) [PSCustomObject]@{ IssuedAt_LocalTime = $TokenObject.issued_at ExpirationTime_LocalTime = $Expires TimeUntilExpiration = $Expires - $Now IsExpired = $Now -gt $Expires } } catch { throw } } |