Last active
          August 20, 2025 11:53 
        
      - 
      
- 
        Save jpawlowski/f6ec653d54ea88e144a80c4f9ff8b64f to your computer and use it in GitHub Desktop. 
    An Azure Automation Runbook that will help to generate new Temporary Access Pass codes for new employees in Microsoft Entra.
  
        
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | <# | |
| .SYNOPSIS | |
| Create a Temporary Access Pass code for new hires that have not setup any Authentication Methods so far | |
| .DESCRIPTION | |
| Create a Temporary Access Pass code for new hires that have not setup any Authentication Methods so far. | |
| .PARAMETER UserId | |
| User account identifier. May be an Entra Identity Object ID or User Principal Name (UPN). | |
| .PARAMETER StartDateTime | |
| The date and time when the Temporary Access Pass becomes available to use. Needs to be in Universal Time (UTC). | |
| .PARAMETER LifetimeInMinutes | |
| The lifetime of the Temporary Access Pass in minutes starting at StartDateTime. Must be between 10 and 43200 inclusive (equivalent to 30 days). | |
| .PARAMETER IsUsableOnce | |
| Determines whether the pass is limited to a one-time use. If true, the pass can be used once; if false, the pass can be used multiple times within the Temporary Access Pass lifetime. | |
| .PARAMETER SendEmailToManager | |
| Send the Temporary Access Pass to the user's manager via email. | |
| .PARAMETER SenderEmailAddress | |
| The email address of the sender. Can be a user or shared mailbox address. | |
| If not specified, the default sender address is used. | |
| .PARAMETER EmailLanguage | |
| The language to use for the email notification. If not specified, the user's preferred language is used. | |
| Supported languages: 'en', 'de' | |
| .PARAMETER EmailSubject | |
| Custom email subject to use when sending notification to manager. | |
| If not specified, the default subject is used. | |
| Variables may be used, see EmailTemplate parameter for available variables. | |
| Multiple languages can be defined using the JSON format: {"en": "English subject", "de": "German subject"} | |
| .PARAMETER EmailTitle | |
| Custom email title to use when sending notification to manager. | |
| If not specified, the default title is used. | |
| Variables may be used, see EmailTemplate parameter for available variables. | |
| Multiple languages can be defined using the JSON format: {"en": "English title", "de": "German title"} | |
| .PARAMETER EmailSalutation | |
| Custom email salutation to use when sending notification to manager. | |
| If not specified, the default salutation is used. | |
| Variables may be used, see EmailTemplate parameter for available variables. | |
| Multiple languages can be defined using the JSON format: {"en": "English salutation", "de": "German salutation"} | |
| .PARAMETER EmailClosing | |
| Custom email closing phrase to use when sending notification to manager. | |
| If not specified, the default closing phrase is used. | |
| Variables may be used, see EmailTemplate parameter for available variables. | |
| Multiple languages can be defined using the JSON format: {"en": "English closing", "de": "German closing"} | |
| .PARAMETER EmailTemplate | |
| Custom email template to use when sending notification to manager. | |
| Uses {{ variable }} syntax for variable substitution. | |
| Available variables: userGivenName, userName, userPrincipalName, managerName, | |
| temporaryAccessPass, lifetimeInMinutes, expirationTime, startDateTime, EmailTitle, EmailClosing. | |
| Images can be embedded using {{ image:imageName }} placeholders. | |
| Multiple languages can be defined using the JSON format: {"en": "English template", "de": "German template"} | |
| .PARAMETER EmailImagesJson | |
| A JSON string representing image references to embed in the email template. | |
| Format: {"imageName1": "base64string1", "imageName2": "base64string2"} | |
| Each image can be referenced in the template as {{ image:imageName1 }} | |
| Example: '{"logo": "data:image/png;base64,iVBORw0KG..."}' | |
| .PARAMETER UseHtmlEmail | |
| When specified, sends the email as HTML format. Default is true. | |
| Set to false to send plain text email. | |
| .PARAMETER OutputJson | |
| Output the result in JSON format | |
| .PARAMETER OutputText | |
| Output the Temporary Access Pass only. | |
| .PARAMETER Simulate | |
| Same as -WhatIf parameter but makes it available for Azure Automation. | |
| .PARAMETER WebhookSignatureKey | |
| Shared secret used to validate incoming webhook requests using HMAC HTTP request header authorization. | |
| When running in Azure Automation, this parameter is automatically populated from the 'AuthConfig_WebhookSignatureKey' automation variable. | |
| .PARAMETER WebhookData | |
| Webhook data object for Azure Automation. | |
| The following parameters may be overridden by the webhook request: | |
| UserId, StartDateTime, LifetimeInMinutes, IsUsableOnce, EmailLanguage | |
| .NOTES | |
| Filename: New-Temporary-Access-Pass-for-Initial-MFA-Setup-V2.ps1 | |
| Author: Julian Pawlowski, Workoho GmbH <[email protected]> | |
| Version: 2.2.0 | |
| #> | |
| #Requires -Version 7.4 | |
| #Requires -Modules @{ ModuleName='Microsoft.Graph.Authentication'; ModuleVersion='2.0' } | |
| using namespace System.Collections.Generic | |
| using namespace System.Text | |
| [CmdletBinding( | |
| SupportsShouldProcess, | |
| ConfirmImpact = 'Medium' | |
| )] | |
| param ( | |
| [string]$UserId, | |
| [datetime]$StartDateTime, | |
| [int64]$LifetimeInMinutes, | |
| [bool]$IsUsableOnce = $false, | |
| [bool]$SendEmailToManager = $false, | |
| [string]$SenderEmailAddress, | |
| [string]$EmailLanguage, | |
| [string]$EmailSubject, | |
| [string]$EmailTitle, | |
| [string]$EmailSalutation, | |
| [string]$EmailClosing, | |
| [string]$EmailTemplate, | |
| [string]$EmailImagesJson, | |
| [bool]$UseHtmlEmail = $true, | |
| [bool]$OutJson = $false, | |
| [bool]$OutText = $false, | |
| [bool]$Simulate = $false, | |
| [securestring]$WebhookSignatureKey, | |
| [object]$WebhookData | |
| ) | |
| #region FUNCTIONS | |
| function Test-HmacAuthorization { | |
| <# | |
| .SYNOPSIS | |
| Verifies HMAC signature of incoming Azure Automation webhook requests. | |
| .DESCRIPTION | |
| Validates HMAC signature including timestamp, nonce, and body hash. | |
| Uses SecureString for shared secret, with proper secure cleanup. Supports SHA256 and SHA512. | |
| Nonce uniqueness checking is left open for future enhancement. | |
| .FUNCTIONALITY | |
| Security, HMAC Authentication | |
| .EXAMPLE | |
| Test-HmacAuthorization -SharedSecret (Get-AutomationVariable -Name 'SharedSecret') -WebhookData $WebhookData | |
| .NOTES | |
| Author: Julian Pawlowski | |
| Company Name: Workoho GmbH | |
| Created: 2025-03-17 | |
| Updated: 2025-03-18 | |
| #> | |
| param( | |
| # The shared secret key used for HMAC calculation (SecureString) | |
| [Parameter(Mandatory)][securestring]$SharedSecret, | |
| # The full webhook request data object passed by Azure Automation (includes headers and body) | |
| [Parameter(Mandatory)][object]$WebhookData, | |
| # Allowed time difference in minutes for timestamp validation (default: 5 minutes) | |
| [int]$AllowedTimeDriftMinutes = 5, | |
| # Expected request path (used in canonical message construction) | |
| [string]$ExpectedPath = '/webhooks' | |
| ) | |
| $allowedAlgorithms = @('HMACSHA256', 'HMACSHA512') | |
| $headers = $WebhookData.RequestHeader | |
| $authHeader = $headers.'x-authorization' | |
| if (-not $authHeader) { | |
| Write-Error 'Missing x-authorization header' | |
| return $false | |
| } | |
| if ($authHeader -notmatch '^HMAC-(?<Algorithm>[A-Z0-9]+)\s+SignedHeaders=(?<SignedHeaders>[^&]+)&Signature=(?<Signature>.+)$') { | |
| Write-Error 'Invalid x-authorization header format' | |
| return $false | |
| } | |
| $algorithm = "HMAC$($matches['Algorithm'])" | |
| if ($allowedAlgorithms -notcontains $algorithm) { | |
| Write-Error "Algorithm $algorithm not allowed" | |
| return $false | |
| } | |
| $signedHeaders = $matches['SignedHeaders'].Split(';') | |
| $receivedHmac = $matches['Signature'] | |
| foreach ($header in $signedHeaders) { | |
| if ([string]::IsNullOrEmpty($headers.$header)) { | |
| Write-Error "Missing signed header: $header" | |
| return $false | |
| } | |
| } | |
| # Timestamp freshness check | |
| if ([string]::IsNullOrEmpty($headers.'x-ms-date')) { | |
| Write-Error 'x-ms-date header required' | |
| return $false | |
| } | |
| else { | |
| try { | |
| $requestTime = [datetime]::Parse($headers.'x-ms-date').ToUniversalTime() | |
| $currentTime = (Get-Date).ToUniversalTime() | |
| if ([math]::Abs(($currentTime - $requestTime).TotalMinutes) -gt $AllowedTimeDriftMinutes) { | |
| Write-Error 'Request timestamp expired' | |
| return $false | |
| } | |
| } | |
| catch { | |
| Write-Error 'Invalid timestamp format' | |
| return $false | |
| } | |
| } | |
| # Body hash check | |
| if ([string]::IsNullOrEmpty($headers.'x-ms-content-sha256')) { | |
| Write-Error "x-ms-content-sha256 header required" | |
| return $false | |
| } | |
| else { | |
| $bodyBytes = [Text.Encoding]::UTF8.GetBytes($WebhookData.RequestBody) | |
| $computedBodyHash = [Convert]::ToBase64String(([System.Security.Cryptography.SHA256]::Create()).ComputeHash($bodyBytes)) | |
| if ($headers.'x-ms-content-sha256' -ne $computedBodyHash) { | |
| Write-Error "Content hash mismatch" | |
| return $false | |
| } | |
| } | |
| # Nonce check | |
| if ([string]::IsNullOrEmpty($headers.'x-ms-nonce')) { | |
| Write-Error "x-ms-nonce header required" | |
| return $false | |
| } | |
| # Optional future: Implement nonce storage to prevent re-use attacks | |
| # Canonical message construction | |
| $method = 'POST' | |
| $path = $ExpectedPath | |
| $headerValues = foreach ($header in $signedHeaders) { $headers.$header } | |
| $canonicalMessage = "$method`n$path`n" + ($headerValues -join ';') | |
| $unsecureSecret = $null | |
| try { | |
| $hmac = New-Object ("System.Security.Cryptography.$algorithm") | |
| # SecureString → plaintext | |
| $unsecureSecret = [System.Net.NetworkCredential]::New("", $SharedSecret).Password | |
| $hmac.Key = [Text.Encoding]::UTF8.GetBytes($unsecureSecret) | |
| $computedHmac = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonicalMessage))) | |
| # Cleanup HMAC key | |
| [Array]::Clear($hmac.Key, 0, $hmac.Key.Length) | |
| $hmac = $null | |
| } | |
| finally { | |
| if ($unsecureSecret) { | |
| [Array]::Clear($unsecureSecret.ToCharArray(), 0, $unsecureSecret.Length) | |
| $unsecureSecret = $null | |
| } | |
| } | |
| # Final comparison (constant-time) | |
| return [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals( | |
| [Text.Encoding]::UTF8.GetBytes($computedHmac), | |
| [Text.Encoding]::UTF8.GetBytes($receivedHmac) | |
| ) | |
| } | |
| function Get-HmacSignedHeaders { | |
| <# | |
| .SYNOPSIS | |
| Generates signed headers for HMAC authentication for Azure Automation webhook requests. | |
| .DESCRIPTION | |
| Generates signed headers for HMAC authentication based on a shared secret (SecureString), URL, and request body. | |
| Includes timestamp, nonce, and content hash, all covered in the signature. | |
| SecureString is converted as late as possible and securely cleaned up after use. | |
| .FUNCTIONALITY | |
| Security, HMAC Authentication | |
| .EXAMPLE | |
| $headers = Get-HmacSignedHeaders -SharedSecret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) -WebhookUrl "https://example.com/webhooks" -Body '{"foo":"bar"}' | |
| .NOTES | |
| Author: Julian Pawlowski | |
| Company Name: Workoho GmbH | |
| Created: 2025-03-17 | |
| Updated: 2025-03-18 | |
| #> | |
| param( | |
| # Shared secret used for HMAC signature (SecureString) | |
| [Parameter(Mandatory)][securestring]$SharedSecret, | |
| # Full webhook URL to which the request will be sent | |
| [Parameter(Mandatory)][string]$WebhookUrl, | |
| # Request body content as a string (usually JSON or similar) | |
| [Parameter(Mandatory)][string]$Body, | |
| # Algorithm to use for HMAC signature (default: HMACSHA256) | |
| [string]$Algorithm = "HMACSHA256", | |
| # Optional: Provide custom nonce (for testing/debugging); defaults to a new random GUID if omitted | |
| [string]$Nonce | |
| ) | |
| if ($Algorithm -notin @('HMACSHA256', 'HMACSHA512')) { | |
| throw "Unsupported algorithm: $Algorithm" | |
| } | |
| # Parse URL components | |
| $uri = [System.Uri]$WebhookUrl | |
| $method = "POST" | |
| $path = $uri.AbsolutePath | |
| $webHost = $uri.Host | |
| # Timestamp header (RFC1123 format) | |
| $date = (Get-Date).ToUniversalTime().ToString("R") | |
| # Compute body hash (SHA256) | |
| $bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body) | |
| $contentHash = [Convert]::ToBase64String(([System.Security.Cryptography.SHA256]::Create()).ComputeHash($bodyBytes)) | |
| # Generate nonce if not provided | |
| if (-not $Nonce) { | |
| $Nonce = [Guid]::NewGuid().ToString() | |
| } | |
| # Define signed headers and order | |
| $signedHeadersList = @('x-ms-date', 'Host', 'x-ms-content-sha256', 'x-ms-nonce') | |
| $signedHeaders = ($signedHeadersList -join ';') | |
| # Build canonical message | |
| $canonicalMessage = "$method`n$path`n$date;$webHost;$contentHash;$Nonce" | |
| $unsecureSecret = $null | |
| $unsecurePtr = [IntPtr]::Zero | |
| try { | |
| $hmac = New-Object ("System.Security.Cryptography.$Algorithm") | |
| # Convert SecureString to unmanaged pointer | |
| $unsecurePtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SharedSecret) | |
| $unsecureSecret = [Runtime.InteropServices.Marshal]::PtrToStringAuto($unsecurePtr) | |
| # Set key and compute signature | |
| $hmac.Key = [Text.Encoding]::UTF8.GetBytes($unsecureSecret) | |
| $signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonicalMessage))) | |
| # Cleanup HMAC key | |
| [Array]::Clear($hmac.Key, 0, $hmac.Key.Length) | |
| $hmac = $null | |
| } | |
| finally { | |
| if ($unsecureSecret) { | |
| [Array]::Clear($unsecureSecret.ToCharArray(), 0, $unsecureSecret.Length) | |
| $unsecureSecret = $null | |
| } | |
| if ($unsecurePtr -ne [IntPtr]::Zero) { | |
| [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($unsecurePtr) | |
| } | |
| } | |
| # Prepare headers (Host excluded intentionally) | |
| $headers = @{ | |
| 'x-ms-date' = $date | |
| 'x-ms-content-sha256' = $contentHash | |
| 'x-ms-nonce' = $Nonce | |
| 'x-authorization' = "HMAC-$($Algorithm.Replace('HMAC','')) SignedHeaders=$signedHeaders&Signature=$signature" | |
| } | |
| return $headers | |
| } | |
| function Assert-ParameterType { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Name, | |
| [Parameter(Mandatory = $true)] | |
| $Value, | |
| [Parameter(Mandatory = $true)] | |
| [type[]]$ExpectedTypes | |
| ) | |
| if ($null -eq $Value) { return $false } | |
| # Check if the value type matches any of the expected types | |
| $matchFound = $false | |
| foreach ($type in $ExpectedTypes) { | |
| if ($Value -is $type) { | |
| $matchFound = $true | |
| break | |
| } | |
| } | |
| if (-not $matchFound) { | |
| $typeNames = $ExpectedTypes | ForEach-Object { "[$($_.Name)]" } | |
| throw "Parameter '$Name' must be one of these types: $($typeNames -join ', '), but received [$($Value.GetType().Name)]" | |
| } | |
| return $true | |
| } | |
| function Invoke-ResilientRemoteCall { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [scriptblock]$ScriptBlock | |
| ) | |
| $maxRetries = 3 | |
| $retryCount = 0 | |
| $backoffInterval = 15 | |
| do { | |
| try { | |
| return Invoke-Command -ScriptBlock $ScriptBlock | |
| } | |
| catch { | |
| if ($retryCount -ge $maxRetries) { | |
| Write-Verbose "Operation failed after $maxRetries attempts: $ScriptBlock" | |
| throw | |
| } | |
| $retryCount++ | |
| Write-Verbose "Operation failed, retrying in $backoffInterval seconds (Attempt $retryCount of $maxRetries)" | |
| Start-Sleep -Seconds $backoffInterval | |
| } | |
| } while ($true) | |
| } | |
| function Get-OrganizationInfo { | |
| [CmdletBinding()] | |
| param() | |
| try { | |
| $organization = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method GET -Uri "/v1.0/organization" -ErrorAction Stop | |
| } | |
| if (-not $organization -or -not $organization.value -or $organization.value.Count -eq 0) { | |
| Write-Verbose "Could not retrieve organization information" | |
| return $null | |
| } | |
| return $organization.value[0] | |
| } | |
| catch { | |
| Write-Warning "Failed to retrieve tenant information: $_" | |
| return $null | |
| } | |
| } | |
| function Get-TenantBrandingImages { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$TenantId | |
| ) | |
| try { | |
| $brandingImages = @{} | |
| # Get the branding configuration (contains URLs, not binary data) | |
| try { | |
| $branding = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method GET ` | |
| -Uri "/v1.0/organization/$TenantId/branding" ` | |
| -ErrorAction Stop | |
| } | |
| if (-not $branding -or -not $branding.cdnList -or $branding.cdnList.Count -eq 0) { | |
| Write-Verbose "No branding CDN information found" | |
| return $null | |
| } | |
| # Use the first CDN domain | |
| $cdnDomain = 'https://' + $branding.cdnList[0] + '/' | |
| Write-Verbose "Using CDN domain: $cdnDomain" | |
| # Process banner logo | |
| if (-not [string]::IsNullOrEmpty($branding.bannerLogoRelativeUrl)) { | |
| try { | |
| $logoUrl = "$cdnDomain$($branding.bannerLogoRelativeUrl)" | |
| Write-Verbose "Retrieving banner logo from: $logoUrl" | |
| $response = Invoke-ResilientRemoteCall { | |
| Invoke-WebRequest -Uri $logoUrl -ErrorAction Stop | |
| } | |
| if ($response.StatusCode -eq 200) { | |
| # Get content type from response headers or determine from content | |
| $contentType = $response.Headers['Content-Type'] | |
| # Check if content type is generic or missing | |
| if ([string]::IsNullOrEmpty($contentType) -or $contentType -eq 'image/*') { | |
| $contentType = Get-ImageContentTypeFromBytes -ImageBytes $response.Content | |
| Write-Verbose "Determined banner logo content type from binary data: $contentType" | |
| } | |
| $base64 = [System.Convert]::ToBase64String($response.Content) | |
| $brandingImages["tenantBannerLogo"] = "data:$contentType;base64,$base64" | |
| Write-Verbose "Retrieved tenant banner logo ($contentType)" | |
| } | |
| } | |
| catch { | |
| Write-Verbose "Failed to retrieve banner logo: $_" | |
| } | |
| } | |
| # Process square logo (light) | |
| if (-not [string]::IsNullOrEmpty($branding.squareLogoRelativeUrl)) { | |
| try { | |
| $logoUrl = "$cdnDomain$($branding.squareLogoRelativeUrl)" | |
| Write-Verbose "Retrieving square logo from: $logoUrl" | |
| $response = Invoke-ResilientRemoteCall { | |
| Invoke-WebRequest -Uri $logoUrl -ErrorAction Stop | |
| } | |
| if ($response.StatusCode -eq 200) { | |
| # Get content type from response headers or determine from content | |
| $contentType = $response.Headers['Content-Type'] | |
| # Check if content type is generic or missing | |
| if ([string]::IsNullOrEmpty($contentType) -or $contentType -eq 'image/*') { | |
| $contentType = Get-ImageContentTypeFromBytes -ImageBytes $response.Content | |
| Write-Verbose "Determined square logo content type from binary data: $contentType" | |
| } | |
| $base64 = [System.Convert]::ToBase64String($response.Content) | |
| $brandingImages["tenantSquareLogoLight"] = "data:$contentType;base64,$base64" | |
| Write-Verbose "Retrieved tenant square logo (light) ($contentType)" | |
| } | |
| } | |
| catch { | |
| Write-Verbose "Failed to retrieve square logo: $_" | |
| } | |
| } | |
| # Process square logo dark | |
| if (-not [string]::IsNullOrEmpty($branding.squareLogoDarkRelativeUrl)) { | |
| try { | |
| $logoUrl = "$cdnDomain$($branding.squareLogoDarkRelativeUrl)" | |
| Write-Verbose "Retrieving dark square logo from: $logoUrl" | |
| $response = Invoke-ResilientRemoteCall { | |
| Invoke-WebRequest -Uri $logoUrl -ErrorAction Stop | |
| } | |
| if ($response.StatusCode -eq 200) { | |
| # Get content type from response headers or determine from content | |
| $contentType = $response.Headers['Content-Type'] | |
| # Check if content type is generic or missing | |
| if ([string]::IsNullOrEmpty($contentType) -or $contentType -eq 'image/*') { | |
| $contentType = Get-ImageContentTypeFromBytes -ImageBytes $response.Content | |
| Write-Verbose "Determined dark square logo content type from binary data: $contentType" | |
| } | |
| $base64 = [System.Convert]::ToBase64String($response.Content) | |
| $brandingImages["tenantSquareLogoDark"] = "data:$contentType;base64,$base64" | |
| Write-Verbose "Retrieved tenant square logo (dark) ($contentType)" | |
| } | |
| } | |
| catch { | |
| Write-Verbose "Failed to retrieve dark square logo: $_" | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose "Failed to retrieve organization branding: $_" | |
| } | |
| return $brandingImages | |
| } | |
| catch { | |
| Write-Warning "Failed to retrieve tenant branding: $_" | |
| return $null | |
| } | |
| } | |
| # Helper function to determine image content type from binary data | |
| function Get-ImageContentTypeFromBytes { | |
| [CmdletBinding()] | |
| [OutputType([string])] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [byte[]]$ImageBytes | |
| ) | |
| # Check file signature (magic numbers) to determine file type | |
| if ($ImageBytes.Length -gt 4) { | |
| # JPEG: FF D8 FF | |
| if ($ImageBytes[0] -eq 0xFF -and $ImageBytes[1] -eq 0xD8 -and $ImageBytes[2] -eq 0xFF) { | |
| return "image/jpeg" | |
| } | |
| # PNG: 89 50 4E 47 | |
| elseif ($ImageBytes[0] -eq 0x89 -and $ImageBytes[1] -eq 0x50 -and | |
| $ImageBytes[2] -eq 0x4E -and $ImageBytes[3] -eq 0x47) { | |
| return "image/png" | |
| } | |
| # GIF: 47 49 46 38 | |
| elseif ($ImageBytes[0] -eq 0x47 -and $ImageBytes[1] -eq 0x49 -and | |
| $ImageBytes[2] -eq 0x46 -and $ImageBytes[3] -eq 0x38) { | |
| return "image/gif" | |
| } | |
| # BMP: 42 4D | |
| elseif ($ImageBytes[0] -eq 0x42 -and $ImageBytes[1] -eq 0x4D) { | |
| return "image/bmp" | |
| } | |
| # WebP: 52 49 46 46 xx xx xx xx 57 45 42 50 | |
| elseif ($ImageBytes.Length -gt 11 -and | |
| $ImageBytes[0] -eq 0x52 -and $ImageBytes[1] -eq 0x49 -and | |
| $ImageBytes[2] -eq 0x46 -and $ImageBytes[3] -eq 0x46 -and | |
| $ImageBytes[8] -eq 0x57 -and $ImageBytes[9] -eq 0x45 -and | |
| $ImageBytes[10] -eq 0x42 -and $ImageBytes[11] -eq 0x50) { | |
| return "image/webp" | |
| } | |
| # SVG: Check if it starts with <?xml or <svg | |
| elseif ($ImageBytes.Length -gt 5) { | |
| $possibleXml = [System.Text.Encoding]::ASCII.GetString($ImageBytes[0..5]) | |
| if ($possibleXml -match '^<\?xml' -or $possibleXml -match '^<svg') { | |
| return "image/svg+xml" | |
| } | |
| } | |
| } | |
| # Default to png if unknown | |
| Write-Verbose "Could not determine image type from magic bytes, defaulting to PNG" | |
| return "image/png" | |
| } | |
| function Expand-Template { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Template, | |
| [Parameter(Mandatory = $true)] | |
| [hashtable]$Variables | |
| ) | |
| $result = $Template | |
| # Replace each variable placeholder with its value | |
| foreach ($key in $Variables.Keys) { | |
| # Match "{{" followed by any whitespace, then the variable name, then any whitespace, then "}}" | |
| $placeholder = [regex]::new("\{\{\s*" + [regex]::Escape($key) + "\s*\}\}") | |
| # Escape $ in replacement value to prevent regex substitution issues | |
| $value = if ($null -eq $Variables[$key]) { "" } else { | |
| [regex]::Replace($Variables[$key].ToString(), '[$]', '$$$$') | |
| } | |
| $result = $placeholder.Replace($result, $value) | |
| } | |
| # Look for any remaining {{ variable }} patterns that weren't replaced - using flexible whitespace | |
| $unreplacedVariables = [regex]::Matches($result, '\{\{\s*[\w\.]+\s*\}\}') | |
| if ($unreplacedVariables.Count -gt 0) { | |
| # Extract just the variable names for clearer reporting | |
| $variableNames = $unreplacedVariables | ForEach-Object { | |
| $match = $_.Value -replace '\{\{\s*([\w\.]+)\s*\}\}', '$1' | |
| $match | |
| } | |
| Write-Verbose "Warning: Found unreplaced template variables: $($variableNames -join ', ')" | |
| } | |
| return $result | |
| } | |
| #endregion | |
| #region Azure Automation Webhook data | |
| if ($WebhookData) { | |
| # Convert $WebhookData from JSON string to object | |
| if ($WebhookData -is [string]) { | |
| try { | |
| $WebhookData = ConvertFrom-Json -InputObject $WebhookData -ErrorAction Stop | |
| Write-Verbose 'Converted WebhookData JSON to object' | |
| } | |
| catch { | |
| throw "Invalid WebhookData JSON: $_" | |
| } | |
| } | |
| Write-Verbose "WEBHOOK-START" | |
| Write-Verbose ("object type: {0}" -f $WebhookData.gettype()) | |
| Write-Verbose $WebhookData | |
| Write-Verbose "`n`n" | |
| Write-Verbose $WebhookData.WebhookName | |
| Write-Verbose $WebhookData.RequestBody | |
| Write-Verbose $WebhookData.RequestHeader | |
| Write-Verbose "WEBHOOK-END" | |
| if ([string]::IsNullOrEmpty($WebhookSignatureKey)) { | |
| try { | |
| $WebhookSignatureKey = Get-AutomationVariable -Name 'AuthConfig_WebhookSignatureKey' -ErrorAction Stop | ConvertTo-SecureString -AsPlainText -Force | |
| } | |
| catch { | |
| throw 'WebhookSignatureKey is required when using a webhook' | |
| } | |
| } | |
| if (-not (Test-HmacAuthorization -SharedSecret $WebhookSignatureKey -WebhookData $WebhookData)) { | |
| [void] $WebhookSignatureKey.Dispose() | |
| throw 'Unauthorized webhook request' | |
| } | |
| [void] $WebhookSignatureKey.Dispose() | |
| Write-Verbose 'Webhook request authorized' | |
| try { | |
| $request = ConvertFrom-Json -InputObject $WebhookData.RequestBody -ErrorAction Stop | |
| Write-Verbose 'Request JSON parsed' | |
| } | |
| catch { | |
| throw 'Invalid Webhook RequestBody JSON' | |
| } | |
| # Always send email to manager when using a webhook | |
| $SendEmailToManager = $true | |
| try { | |
| # Validate and assign parameters | |
| if ($null -ne $request.UserId) { | |
| if (Assert-ParameterType -Name 'UserId' -Value $request.UserId -ExpectedTypes ([string])) { | |
| $UserId = $request.UserId | |
| } | |
| } | |
| if ($null -ne $request.StartDateTime) { | |
| if (Assert-ParameterType -Name 'StartDateTime' -Value $request.StartDateTime -ExpectedTypes ([datetime])) { | |
| $StartDateTime = $request.StartDateTime | |
| } | |
| } | |
| if ($null -ne $request.LifetimeInMinutes) { | |
| if (Assert-ParameterType -Name 'LifetimeInMinutes' -Value $request.LifetimeInMinutes -ExpectedTypes ([Int32], [Int64])) { | |
| try { | |
| $LifetimeInMinutes = [Int32] $request.LifetimeInMinutes | |
| } | |
| catch { | |
| throw "Parameter 'LifetimeInMinutes' must be a valid integer" | |
| } | |
| } | |
| } | |
| if ($null -ne $request.IsUsableOnce) { | |
| if (Assert-ParameterType -Name 'IsUsableOnce' -Value $request.IsUsableOnce -ExpectedTypes ([bool])) { | |
| $IsUsableOnce = $request.IsUsableOnce | |
| } | |
| } | |
| if ($null -ne $request.EmailLanguage) { | |
| if (Assert-ParameterType -Name 'EmailLanguage' -Value $request.EmailLanguage -ExpectedTypes ([string])) { | |
| $EmailLanguage = $request.EmailLanguage | |
| } | |
| } | |
| # Check for other unexpected parameters | |
| $validParameters = @( | |
| 'UserId', 'StartDateTime', 'LifetimeInMinutes', 'IsUsableOnce', 'EmailLanguage' | |
| ) | |
| $unexpectedParams = $request.PSObject.Properties.Name | Where-Object { $_ -notin $validParameters } | |
| if ($unexpectedParams) { | |
| Write-Warning "Ignoring unexpected parameters: $($unexpectedParams -join ', ')" | |
| } | |
| } | |
| catch { | |
| throw "Invalid Webhook RequestBody: $_" | |
| } | |
| } | |
| #endregion | |
| if (-not $UserId) { | |
| throw 'UserId is required.' | |
| } | |
| # Process email images from JSON if provided | |
| $EmailImages = @{} | |
| if (-not [string]::IsNullOrEmpty($EmailImagesJson)) { | |
| try { | |
| $parsedImages = ConvertFrom-Json -InputObject $EmailImagesJson -AsHashtable -ErrorAction Stop | |
| if ($parsedImages -is [hashtable] -or $parsedImages -is [System.Collections.IDictionary]) { | |
| $EmailImages = $parsedImages | |
| } | |
| } | |
| catch { | |
| Write-Warning "Failed to parse EmailImagesJson parameter: $_" | |
| } | |
| } | |
| # Default email images | |
| $EmailImages.iconWarning = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFcklEQVR4nO2Ve2iVZRzHD4Gey/uendt7WmzSQovFduYfpu4+z5xzkUSE3TAyDLKQ8A8hCsp/imJmQUaRgww0RPA6NdfOzs5lOzfPNndeaARGRSAp4khkMvHsPO833ufcb9vznq25Yj/4wGBjfL7f9/c8j0q1PMuzPAs2gOohiNanIVp/gChMQLTeoYwLE4gIxyBau+S/US3FgWhtgShEIFpBieQiAOMylisYtzaplsoguEoLUehGxErypa0J6RzGBAlXhB5MWPkHKy9ankVE+INJ+koSS5oxyzWMmncurvSYagVE4WVEhEDhFZlTOp8Rsw9h00vy/17Y1YiYKyGW2xCxtEMU9kEUehERJktsO5tRcz5h8y2MmM4hbNqHUYMdIyYbguZK2UWZvPhwIHUYZz2QStsuID2SxJQmnI102Qhy2eBTEKCQNGPbY4xt54qHC4tnwh5gHisijVow5TPjltOYxdSwCVKYrW0pl1AcBQFKP5DRkBm9h9Zg2zNd6OrqQmdnJ7q6OnDxqypEg+xtS6FMDBT2APNYkftBM777uJbKb926lQbo6OjA9x89gWiAvW0pIS0F07AHmMeBjAbNOPpJdUp+y5YtNMDx7jU0AJN0KFucElAUoPQDKa/Jic8ez5LfvHkzTn3+GKKB2VdEChYSL0vBHqCUWySx2zNBE05/sTpLvr29Hee/fDQeQKG0lMSvOMBs0sWvPznAhUNVVDwpb7fb0f91JWZogOIrIhWQTqNXEGAed/ZM0Ij+b1alxGU2bdoEb88jmPGzty0lpCm+OAoCmO6xvpC5yC27D1ekxGXa2toQOlKOmJ9FuixPXIYM66fZA4yYbih5ITMPZCxgQOBIOZWWaW1tpYwfExDzKZOWkgzzIEP8X+wBwsawkhcyc6/llseOWlPiLS0tlF9OmEH8s6+IlCOdCRnmgkoC9Ch5ITN3Wg7w83ELlW5ubqY0NTXht1MGEN8c4sP54tJQHOLlDisIYNqh5IXMPJByy1dPGql0ksbGRlzrLWNuWxrKhKPAw73CHmDMZCAh4322FzJ7r+UAf541UOmGhgZKY0M9bl7kmduWkuLeOMTDReExGFVKhoSM5xQ9NhkH8voFPerr6ykbN25EW8t63LrEM7ctJcTTAXRnFMnTrxAwtpV6Z0/28di1vQb21nWUPTuq8XdfMXGuoDTFo6PArWlWHIB+hYDBz/JC5u521KunwpOXOMrtPh4zXra2JU9aXIa4dcMlydOvEDSsI/4yovTOjnl53HUmAvzIYcrBI+Zha1tK4qbyBE7thpID0K/g1/fMLZ3ebVn+xgUe+3dXwd5sg72lDh++WYUb53jE3LO3LSXEkxC35tt5ydOv4CjniJ//lfX6m3byOLB3FdauXZuirq4O3e9UYnpgbmnJraUQl/Z3+FX6eQegIQJlG4hPf6+gdM6BvPMTh13Pr6HSMjabjfL6c6tx59Ls0pIrDhnUTsOjXb8g8qkQPt1OlutPbvng3grU1tZSampqKJ/uqcC0o4C4KxsyqJXg1Ly6oPKpEEP8/rnvbA43ezl88EYlGtdXo+Gparz7WiWun+ZAXPltS5kMagCn5n3VvznEyx0sfIukdzvm1uFuvw6T5+NM9ekQcxWT1lJxGeJUH1AtxsCre4/lFinadoa0lJbvXhT5VAiP9m3i0RFm6cSKZOGUxTUxDKrfWlT5jBAvELfubrEDWaxtWZzKD2im4Fy5/YHIp0OsfJIMaiZYpeOoQQbUV+FYYVMthZEfHOLSnmQRlwao/An0LtAjtZADl/ZFMqiezJNOijvUt+FQ71Yt5cGAtoI41WeT0kmIQ31G/p3qvzLoV3eSAfWEjPzzg/ZZnuX5v84/V/4B6rP1Gg4AAAAASUVORK5CYII=' | |
| $EmailImages.iconInfo = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFCUlEQVR4nO2YSUwbVxjHh1Ztis0SttisxmYxOxjUVL300kMvPfVS9dBL21SqVKlS1ahVKwW1YicYs+9hM5iYQIMDDmCsSlVTtRV7IDDeVzCZYcapoqAoKl81PtpRB88brB74S3Md/X5v3vfm+x6GXeQiF+Elb/5yJK5YPv6ofIm6WbZILZTpj1dLF0hL8QJpLZ4nV4t1xP0iHdFaqCM+zrtHpmP/h1TpvILyZfqawkD/VrF0fFq+REH5IgVl94+hVH8MJQsklMyTUHyPgCIdAYVzBBTefQwFPz8G+ezRXwV3jr6U3yViIw5epIXXK430dcUKdaQw0FCxTEGY8CCfOYL8O0eQN+2jcrW+GxlaV3RE4BWGJ28rVug9xQoNPMBDrtYHubcPQaY5sMk0vnfPFb7SSH+jWKFf8A2fM8UIHIJs0vuPVO39EQOI4pccIKrS6G+pNPrh/OAPQDpxANlqL0jGPeqqvtXXeOOvMvpVkYP3gmTMC1kjbg1WDa/wAf9dxOFHPZA54oGMYVcDErzCSL3Ddc/LtW7IU5shb9wMeRoXB3g3ZAy5TtOGPO9zgs/Vw6VKI41zgS/S2ED9iADq5EXgGd8hIH/cEh78LTekD7khbdDlkwzbL4ctULniv8Fp5ac9UPPHAQTnpwdekKld4cJD2oALUvsdyrDgr+rJOIWBorns+fwpJ0w+IkMEJnYIkI7aOcA7QdTrPEnqcKadWUBhoL/mWrD5Wi9cW3aECHyit4N03BU2vLgvIACibkfNmQUqluktlNMmR22Dz5cccBcnA8+nDPywhTt8jwNSuhzOMx2rZYYn+bwclRoPyEZtIB2xgUztRoK/0s0I2CGx0/YW++obqC9Q4XPGrJA9sAuS/l2Q9O1AVu8OSIbMSPDJnXZIbrd+z/4FFo+HUX9SDHxwMrsfosF32CCxzTp3BgHqd9Q/LLPqIQJd20jwSe02SFBZ99gF9KQTtT2Q9D4MEcjo3EKCT2xjBCxPWQVK9SSF2ttk9YQKpHdsIcEnqqyQoLScYlp49T8FiucJP2pjltm9HSKQ1rGJBt9qhctKC2BsbXbJPOlF7SpfKtC+iQwf32I+Yd1CxTpiA7UlZgo2OKltm4jwFohrNjtZBYrmiCnUfp4p2OCIVRtI8PHNFohtNC+xChTMEd+iDiNMwYYItG4gwcc1mRmBRvYvMEteRZ2kmIINjki5jgoPggbze6wCzDEln/V5uI+B7kDBBudKyzoSvLAB92PV9jfYBTAMk8/4GrnCM0clU7DBSbm5zhk+psEEwnp8EDtrZFpvVu607zkXeOaoZAo2RKB5jTN8TB1+KqzdL8PCSc5t3y2OM2ygYIOT3LTGDb7eBMJa0ywWbqSTPlGO5pDmMsOKlKECSU3rHOHxZ5fqcVnYAgGJCd9nnGbYjr3Anme2DbPySY1rkKTcDR++zgSCGvw6hpJs9cEk6iTFaeXrTBBds7+AfE/KXHtLxjy/Rhy+Ft+Or+ZwH/SyMBdLmSPuBxFc+U1B044Y4zOpfV5BxrBrJgLwSwkN1njsXAIQlT7o/ip1wPnsHOCfC2rwH3i5kWaLqN8hFfc5Z8W9jlN+4PHFmNr9AizSSemxlYt67GMp3fan4cPjJzH1pqnoOpz9rufcRTp3YhK77B8kt9s6k9psfyaqbP7QYcTyd2yjaTW2wdQnrNv/8Pz2OU8RNR8KhSqrSKB0pDKCfL33IhfB+M2/hW3M5uDgqEwAAAAASUVORK5CYII=' | |
| if (-not $WhatIfPreference -and $Simulate) { | |
| $WhatIfPreference = $true | |
| } | |
| if ('AzureAutomation/' -eq $env:AZUREPS_HOST_ENVIRONMENT -or $PSPrivateMetadata.JobId) { | |
| $OutJson = $true | |
| $ProgressPreference = 'SilentlyContinue' | |
| } | |
| # Initialize return object as ordered for consistent output | |
| $return = [ordered]@{} | |
| # Define required Microsoft Graph scopes | |
| $mgScopes = [string[]]@( | |
| 'User.Read.All' # To read user information, inlcuding EmployeeHireDate | |
| 'UserAuthenticationMethod.ReadWrite.All' # To update authentication methods (TAP) of the user | |
| 'Policy.Read.All' # To read and validate current policy settings | |
| 'Directory.Read.All' # To read directory data and settings | |
| ) | |
| # Make sure Mail.Send permission is included if SendEmailToManager is requested | |
| if ($SendEmailToManager) { | |
| $mgScopes += 'MailboxSettings.Read' | |
| $mgScopes += 'Mail.Send' | |
| } | |
| # User validation - reject specific account types | |
| $invalidUserRegexPatterns = @( | |
| '^A[0-9][A-Z][-_].+@.+$', # Tiered admin accounts | |
| '^ADM[CL]?[-_].+@.+$', # Non-Tiered admin accounts | |
| '^.+#EXT#@.+\.onmicrosoft\.com$', # External Accounts | |
| '^(?:SVCC?_.+|SVC[A-Z0-9]+)@.+$', # Service Accounts | |
| '^(?:Sync_.+|[A-Z]+SyncServiceAccount.*)@.+$' # Entra Sync Accounts | |
| ) | |
| if ($invalidUserRegexPatterns | Where-Object { $UserId -match $_ }) { | |
| Write-Error 'This type of user can not have a Temporary Access Pass created using this process.' | |
| exit 1 | |
| } | |
| # Handle Graph authentication | |
| if (-not (Get-MgContext)) { | |
| try { | |
| if ('AzureAutomation/' -eq $env:AZUREPS_HOST_ENVIRONMENT -or $PSPrivateMetadata.JobId) { | |
| $null = Invoke-ResilientRemoteCall { Connect-MgGraph -Identity -ContextScope Process } | |
| Write-Verbose "Connected with managed identity: $((Get-MgContext).Account)" | |
| } | |
| else { | |
| $null = Invoke-ResilientRemoteCall { Connect-MgGraph -Scopes $mgScopes -ContextScope Process } | |
| } | |
| } | |
| catch { | |
| throw "Failed to connect to Microsoft Graph: $_" | |
| } | |
| } | |
| # Check if current scopes are sufficient | |
| $missingScopes = [List[string]]::new() | |
| $currentScopes = (Get-MgContext).Scopes | |
| foreach ($scope in $mgScopes) { | |
| # Skip write scopes if in WhatIf mode | |
| if ($WhatIfPreference -and $scope -like '*Write*') { | |
| Write-Verbose "What If: Removed $scope from required Microsoft Graph scopes" | |
| continue | |
| } | |
| if ($scope -notin $currentScopes) { | |
| $missingScopes.Add($scope) | |
| } | |
| } | |
| if ($missingScopes.Count -gt 0) { | |
| if ('AzureAutomation/' -eq $env:AZUREPS_HOST_ENVIRONMENT -or $PSPrivateMetadata.JobId) { | |
| throw "Missing Microsoft Graph authorization scopes:`n$($missingScopes -join "`n")" | |
| } | |
| else { | |
| # Reconnect with required scopes | |
| try { | |
| $null = Invoke-ResilientRemoteCall { Connect-MgGraph -Scopes $mgScopes -ContextScope Process } | |
| } | |
| catch { | |
| throw "Failed to connect with required scopes: $_" | |
| } | |
| } | |
| } | |
| # Get Temporary Access Pass configuration | |
| try { | |
| $tapConfig = Invoke-ResilientRemoteCall { | |
| (Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/authenticationMethodsPolicy' -ErrorAction Stop).authenticationMethodConfigurations | Where-Object { $_.id -eq 'temporaryAccessPass' } | |
| } | |
| if ($tapConfig.state -ne 'enabled') { | |
| throw "Temporary Access Pass authentication method is disabled for tenant $((Get-MgContext).TenantId)" | |
| } | |
| } | |
| catch { | |
| throw "Failed to retrieve TAP configuration: $_" | |
| } | |
| # Validate parameters | |
| if ($StartDateTime -and ($StartDateTime.ToUniversalTime() -lt (Get-Date).ToUniversalTime().AddMinutes(1))) { | |
| throw 'StartDateTime: Time cannot be in the past.' | |
| } | |
| if ($LifetimeInMinutes) { | |
| if ($LifetimeInMinutes -gt $tapConfig.maximumLifetimeInMinutes) { | |
| $LifetimeInMinutes = $tapConfig.maximumLifetimeInMinutes | |
| Write-Warning "LifetimeInMinutes: Maximum lifetime capped at $LifetimeInMinutes minutes." | |
| } | |
| if ($LifetimeInMinutes -lt $tapConfig.minimumLifetimeInMinutes) { | |
| $LifetimeInMinutes = $tapConfig.minimumLifetimeInMinutes | |
| Write-Warning "LifetimeInMinutes: Minimum lifetime capped at $LifetimeInMinutes minutes." | |
| } | |
| } | |
| if ($SendEmailToManager -and [string]::IsNullOrEmpty($SenderEmailAddress)) { | |
| if ('AzureAutomation/' -eq $env:AZUREPS_HOST_ENVIRONMENT -or $PSPrivateMetadata.JobId) { | |
| throw 'SenderEmailAddress is required when sending email to manager.' | |
| } | |
| $SenderEmailAddress = (Get-MgContext).Account | |
| Write-Verbose "Using authenticated user $SenderEmailAddress as sender email address" | |
| } | |
| # Get user information | |
| try { | |
| $select = 'id,userPrincipalName,mail,displayName,givenName,surname,employeeHireDate,userType,accountEnabled,usageLocation,preferredLanguage' | |
| $expand = 'manager($select=id,userPrincipalName,mail,displayName,givenName,surname,preferredLanguage)' | |
| $userObj = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method GET ` | |
| -Uri "/v1.0/users/$UserId`?`$select=$select&`$expand=$expand" ` | |
| -ErrorAction Stop | |
| } | |
| if ($SendEmailToManager) { | |
| try { | |
| $userObj.mailboxLanguage = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method GET ` | |
| -Uri "/v1.0/users/$UserId/mailboxSettings/language" ` | |
| -ErrorAction Stop | |
| } | |
| Write-Verbose "Retrieved mailbox language: $($userObj.mailboxLanguage.locale)" | |
| } | |
| catch { | |
| Write-Warning "Failed to retrieve mailbox language: $_" | |
| } | |
| if ($null -ne $userObj.manager -and $null -ne $userObj.manager.id) { | |
| try { | |
| $userObj.manager.mailboxLanguage = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method GET ` | |
| -Uri "/v1.0/users/$($userObj.manager.id)/mailboxSettings/language" ` | |
| -ErrorAction Stop | |
| } | |
| Write-Verbose "Retrieved manager mailbox language: $($userObj.manager.mailboxLanguage.locale)" | |
| } | |
| catch { | |
| Write-Warning "Failed to retrieve manager mailbox language: $_" | |
| } | |
| } | |
| } | |
| } | |
| catch { | |
| throw "Failed to retrieve user information: $_" | |
| } | |
| # Validate user account state | |
| if (-not $userObj.accountEnabled) { | |
| Write-Error 'User ID is disabled.' | |
| exit 1 | |
| } | |
| if ($userObj.userType -ne 'Member') { | |
| Write-Error 'User ID needs to be of type Member.' | |
| exit 1 | |
| } | |
| if ($null -ne $userObj.employeeHireDate) { | |
| if ($userObj.employeeHireDate -ge (Get-Date).Date) { | |
| Write-Error 'User ID has a future hire date.' | |
| exit 1 | |
| } | |
| } | |
| if ($null -eq $userObj.manager -or -not $userObj.manager.id) { | |
| Write-Error 'User ID does not have a manager.' | |
| exit 1 | |
| } | |
| if ($SendEmailToManager) { | |
| if ([string]::IsNullOrEmpty($userObj.manager.mail)) { | |
| Write-Error "Cannot send email: Manager for $($userObj.displayName) does not have an email address." | |
| exit 1 | |
| } | |
| } | |
| # Build return data structure | |
| $return.Data = [PSCustomObject]@{ | |
| '@odata.context' = $userObj.'@odata.context' | |
| Id = $userObj.id | |
| UserPrincipalName = $userObj.userPrincipalName | |
| GivenName = $userObj.givenName | |
| Surname = $userObj.surname | |
| Mail = $userObj.mail | |
| DisplayName = $userObj.displayName | |
| EmployeeHireDate = $userObj.employeeHireDate | |
| UserType = $userObj.userType | |
| AccountEnabled = $userObj.accountEnabled | |
| UsageLocation = $userObj.usageLocation | |
| PreferredLanguage = $userObj.mailboxLanguage.locale ?? $userObj.preferredLanguage | |
| Manager = [PSCustomObject]@{ | |
| Id = $userObj.manager.id | |
| UserPrincipalName = $userObj.manager.userPrincipalName | |
| Mail = $userObj.manager.mail | |
| DisplayName = $userObj.manager.displayName | |
| GivenName = $userObj.manager.givenName | |
| Surname = $userObj.manager.surname | |
| PreferredLanguage = $userObj.manager.mailboxLanguage.locale ?? $userObj.manager.preferredLanguage | |
| } | |
| AuthenticationMethods = [List[string]]::new() | |
| } | |
| # Get user security groups | |
| try { | |
| $userGroupsResponse = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method POST ` | |
| -Uri "/v1.0/users/$($userObj.id)/getMemberGroups" ` | |
| -Body @{ securityEnabledOnly = $true } | ConvertTo-Json -ErrorAction Stop | |
| } | |
| $userGroupIds = $userGroupsResponse.value | |
| } | |
| catch { | |
| throw "Failed to retrieve user groups: $_" | |
| } | |
| # Check if user can use TAP based on group memberships | |
| $isExcluded = $false | |
| $isIncluded = $false | |
| # Check exclusion rules | |
| if ($null -ne $tapConfig.excludeTargets) { | |
| $isExcluded = $tapConfig.excludeTargets | | |
| Where-Object { $_.targetType -eq 'group' -and $_.id -in $userGroupIds } | | |
| Select-Object -First 1 | |
| } | |
| # Check inclusion rules | |
| if ($null -ne $tapConfig.includeTargets) { | |
| $isIncluded = $tapConfig.includeTargets | | |
| Where-Object { | |
| $_.targetType -eq 'group' -and ($_.id -eq 'all_users' -or $_.id -in $userGroupIds) | |
| } | | |
| Select-Object -First 1 | |
| } | |
| if ($isExcluded -or -not $isIncluded) { | |
| Write-Error "Authentication method 'Temporary Access Pass' is not enabled for this user ID." | |
| exit 1 | |
| } | |
| # Get user's authentication methods | |
| try { | |
| $authMethods = Invoke-ResilientRemoteCall { | |
| Invoke-MgGraphRequest -Method GET ` | |
| -Uri "/v1.0/users/$($userObj.id)/authentication/methods" ` | |
| -ErrorAction Stop | |
| } | |
| } | |
| catch { | |
| throw "Failed to retrieve authentication methods: $_" | |
| } | |
| # Process authentication methods | |
| foreach ($authMethod in $authMethods.value) { | |
| if ($authMethod.'@odata.type' -match '^#microsoft\.graph\.(.+)AuthenticationMethod$') { | |
| $methodType = $Matches[1] | |
| $return.Data.AuthenticationMethods.Add($methodType) | |
| if ($methodType -eq 'temporaryAccessPass') { | |
| Write-Verbose "Found existing TAP Id $($authMethod.id)" | |
| $return.Data | Add-Member -NotePropertyName 'TemporaryAccessPass' -NotePropertyValue $authMethod | |
| $return.Data.TemporaryAccessPass | Add-Member -NotePropertyName 'Id' -NotePropertyValue $authMethod.id -Force | |
| } | |
| } | |
| } | |
| # Handle existing TAP and authentication methods | |
| if ($return.Data.AuthenticationMethods.Count -gt 0) { | |
| if ('temporaryAccessPass' -in $return.Data.AuthenticationMethods) { | |
| # Check if TAP is the only authentication method besides password | |
| $canDeleteTap = 'password' -in $return.Data.AuthenticationMethods -and | |
| $return.Data.AuthenticationMethods.Count -le 2 | |
| if ($canDeleteTap) { | |
| if ($return.Data.TemporaryAccessPass.methodUsabilityReason -ne 'Expired') { | |
| Write-Warning 'A Temporary Access Pass code was already set before.' | |
| } | |
| if ($PSCmdlet.ShouldProcess( | |
| "Delete existing Temporary Access Pass for $($userObj.userPrincipalName)", | |
| "Do you confirm to remove the existing TAP for $($userObj.userPrincipalName) ?", | |
| 'Delete existing Temporary Access Pass' | |
| )) { | |
| try { | |
| $null = Invoke-ResilientRemoteCall { | |
| if (-not $WhatIfPreference) { | |
| Invoke-MgGraphRequest -Method DELETE ` | |
| -Uri "/v1.0/users/$($userObj.id)/authentication/temporaryAccessPassMethods/$($return.Data.TemporaryAccessPass.Id)" ` | |
| -ErrorAction Stop | |
| } | |
| } | |
| $return.Data.PSObject.Properties.Remove('TemporaryAccessPass') | |
| } | |
| catch { | |
| throw "Failed to delete existing TAP: $_" | |
| } | |
| } | |
| elseif ($WhatIfPreference) { | |
| Write-Verbose 'What If: An existing Temporary Access Pass would have been deleted.' | |
| } | |
| else { | |
| Write-Error 'Deletion of existing Temporary Access Pass was aborted.' | |
| exit 1 | |
| } | |
| } | |
| else { | |
| # User has other authentication methods besides password and TAP | |
| if ($return.Data.TemporaryAccessPass.methodUsabilityReason -eq 'Expired') { | |
| $errorMsg = [StringBuilder]::new() | |
| [void] $errorMsg.Append("An expired Temporary Access Pass code was found. `n") | |
| [void] $errorMsg.Append('However, this process cannot be used to renew the Temporary Access Pass code because you have already configured other multi-factor authentication methods. ') | |
| [void] $errorMsg.Append('Note that a Temporary Access Pass is only required during the initial onboarding process. ') | |
| [void] $errorMsg.Append('You can then use your existing access to register additional methods, for example a security key. ') | |
| [void] $errorMsg.Append('However, if you later lose access to all your multi-factor authentication methods, this self-service process cannot be used to recover. ') | |
| [void] $errorMsg.Append('In this case, please contact the Global Service Desk who will help you reset your MFA methods.') | |
| } | |
| else { | |
| $errorMsg = [StringBuilder]::new() | |
| [void] $errorMsg.Append("An active Temporary Access Pass code has already been found. `n") | |
| [void] $errorMsg.Append('It can only be displayed once after it has been created. ') | |
| [void] $errorMsg.Append('As you have already configured other methods of multi-factor authentication, a new Temporary Access Pass can no longer be created via this self-service process. ') | |
| [void] $errorMsg.Append('In this case, please contact the Global Service Desk who will help you reset your MFA methods.') | |
| } | |
| Write-Error $errorMsg.ToString() | |
| exit 1 | |
| } | |
| } | |
| # Check if user has other authentication methods besides password | |
| elseif ($return.Data.AuthenticationMethods.Count -gt 1 -or 'password' -notin $return.Data.AuthenticationMethods) { | |
| $errorMsg = [StringBuilder]::new() | |
| [void] $errorMsg.Append('This process cannot be used to request a Temporary Access Pass code as you have already configured other multi-factor authentication methods. ') | |
| [void] $errorMsg.Append('Note that a Temporary Access Pass is only required during the initial onboarding process. ') | |
| [void] $errorMsg.Append('You can then use your existing access to register additional methods, for example a security key. ') | |
| [void] $errorMsg.Append('However, if you later lose access to all your multi-factor authentication methods, this self-service process cannot be used to recover. ') | |
| [void] $errorMsg.Append('In this case, please contact the Global Service Desk who will help you reset your MFA methods.') | |
| Write-Error $errorMsg.ToString() | |
| exit 1 | |
| } | |
| } | |
| # Create new TAP if no existing one or it was deleted | |
| if ($WhatIfPreference -or -not ($return.Data.PSObject.Properties['TemporaryAccessPass'])) { | |
| $tapParams = @{} | |
| if ($StartDateTime) { $tapParams.startDateTime = $StartDateTime.ToUniversalTime().ToString("o") } | |
| if ($IsUsableOnce) { $tapParams.isUsableOnce = $true } | |
| if ($LifetimeInMinutes) { $tapParams.lifetimeInMinutes = $LifetimeInMinutes } | |
| if ($PSCmdlet.ShouldProcess( | |
| "Create new Temporary Access Pass for $($userObj.userPrincipalName)", | |
| "Do you confirm to create a new TAP for $($userObj.userPrincipalName) ?", | |
| 'New Temporary Access Pass' | |
| )) { | |
| try { | |
| $tap = Invoke-ResilientRemoteCall { | |
| if ($WhatIfPreference) { | |
| Write-Verbose "What If: Would create a new TAP with parameters: $($tapParams | ConvertTo-Json -Compress)" | |
| return $null | |
| } | |
| else { | |
| Invoke-MgGraphRequest -Method POST ` | |
| -Uri "/v1.0/users/$($userObj.id)/authentication/temporaryAccessPassMethods" ` | |
| -Body ($tapParams | ConvertTo-Json) ` | |
| -ErrorAction Stop | |
| } | |
| } | |
| if ($tap) { | |
| $return.Data | Add-Member -NotePropertyName 'TemporaryAccessPass' -NotePropertyValue $tap -Force | |
| if ('temporaryAccessPass' -notin $return.Data.AuthenticationMethods) { | |
| $return.Data.AuthenticationMethods.Add('temporaryAccessPass') | |
| } | |
| Write-Verbose 'A new Temporary Access Pass code was created.' | |
| } | |
| elseif ($WhatIfPreference) { | |
| # Expected behavior in WhatIf mode | |
| } | |
| else { | |
| throw "Failed to create TAP but no exception was thrown" | |
| } | |
| } | |
| catch { | |
| Write-Error "Failed to create new Temporary Access Pass: $_" | |
| exit 1 | |
| } | |
| } | |
| elseif ($WhatIfPreference) { | |
| Write-Verbose "What If: A new Temporary Access Pass code would have been created with the following parameters:`n$(($tapParams | Out-String).TrimEnd())" | |
| $return.WhatIf = @{ | |
| returnCode = 0 | |
| message = 'A Temporary Access Pass code may be created for this user ID.' | |
| } | |
| } | |
| else { | |
| Write-Error 'Creation of new Temporary Access Pass code was aborted.' | |
| exit 1 | |
| } | |
| } | |
| # Send email to manager | |
| if ($SendEmailToManager) { | |
| $organizationInfo = Get-OrganizationInfo | |
| $languageCode = if ([string]::IsNullOrEmpty($EmailLanguage)) { | |
| if ([string]::IsNullOrEmpty($return.Data.Manager.PreferredLanguage)) { | |
| 'en' | |
| } | |
| else { | |
| $return.Data.Manager.PreferredLanguage.Substring(0, 2).ToLower() | |
| } | |
| } | |
| else { | |
| $EmailLanguage.Substring(0, 2).ToLower() | |
| } | |
| Write-Verbose "Sending email in language: $languageCode" | |
| $hour = if ($languageCode -eq 'de') { 'Stunde' } else { 'hour' } | |
| $hours = if ($languageCode -eq 'de') { 'Stunden' } else { 'hours' } | |
| $minute = if ($languageCode -eq 'de') { 'Minute' } else { 'minute' } | |
| $minutes = if ($languageCode -eq 'de') { 'Minuten' } else { 'minutes' } | |
| # Build allowed variables dictionary for template expansion | |
| $templateVariables = @{ | |
| orgTenantId = $organizationInfo.Id | |
| orgDisplayName = $organizationInfo.DisplayName | |
| orgStreet = $organizationInfo.Street | |
| orgPostalCode = $organizationInfo.PostalCode | |
| orgCity = $organizationInfo.City | |
| orgState = $organizationInfo.State | |
| orgCountry = $organizationInfo.Country ?? $organizationInfo.CountryLetterCode | |
| orgCountryLetterCode = $organizationInfo.CountryLetterCode | |
| orgPrivacyContact = $organizationInfo.PrivacyProfile.ContactEmail | |
| orgPrivacyStatementUrl = $organizationInfo.PrivacyProfile.StatementUrl | |
| userDisplayName = $return.Data.DisplayName | |
| userGivenName = $return.Data.GivenName ?? $return.Data.DisplayName | |
| userSurname = $return.Data.Surname | |
| userPrincipalName = $return.Data.UserPrincipalName | |
| userMail = $return.Data.Mail | |
| managerDisplayName = $return.Data.Manager.DisplayName | |
| managerGivenName = $return.Data.Manager.GivenName ?? $return.Data.Manager.DisplayName | |
| managerSurname = $return.Data.Manager.Surname | |
| managerMail = $return.Data.Manager.Mail | |
| temporaryAccessPass = $return.Data.TemporaryAccessPass.temporaryAccessPass | |
| startTime = Get-Date $return.Data.TemporaryAccessPass.startDateTime -UFormat '%Y-%m-%d %R' | |
| lifetimeInMinutes = $return.Data.TemporaryAccessPass.lifetimeInMinutes.ToString() | |
| lifetimeInHours = [math]::Round($return.Data.TemporaryAccessPass.lifetimeInMinutes / 60, 0).ToString() | |
| lifeTimeInHoursMinutes = [math]::Floor($return.Data.TemporaryAccessPass.lifetimeInMinutes / 60).ToString() + | |
| $(if ([math]::Floor($return.Data.TemporaryAccessPass.lifetimeInMinutes / 60) -eq 1) { " $hour" } else { " $hours" }) + | |
| $(if (($return.Data.TemporaryAccessPass.lifetimeInMinutes % 60) -gt 0) { | |
| ' ' + ($return.Data.TemporaryAccessPass.lifetimeInMinutes % 60).ToString() + | |
| $(if (($return.Data.TemporaryAccessPass.lifetimeInMinutes % 60) -eq 1) { " $minute" } else { " $minutes" }) | |
| } | |
| else { '' }) | |
| expirationTime = Get-Date (Get-Date $return.Data.TemporaryAccessPass.startDateTime).AddMinutes($return.Data.TemporaryAccessPass.lifetimeInMinutes) -UFormat '%Y-%m-%d %R' | |
| } | |
| if ([string]::IsNullOrEmpty($EmailSubject)) { | |
| if ($languageCode -eq 'de') { | |
| $EmailSubject = '[VERTRAULICH] Befristeter Zugriffspass für {{ userDisplayName }}' | |
| } | |
| else { | |
| $EmailSubject = '[CONFIDENTIAL] Temporary Access Pass to onboard {{ userDisplayName }}' | |
| } | |
| } | |
| else { | |
| try { | |
| $EmailSubjects = $EmailSubject | ConvertFrom-Json -AsHashtable -ErrorAction Stop | |
| if ($EmailSubjects -is [hashtable]) { | |
| if ($EmailSubjects.ContainsKey($languageCode)) { | |
| $EmailSubject = $EmailSubjects[$languageCode] | |
| } | |
| elseif ($EmailSubjects.ContainsKey('en')) { | |
| $EmailSubject = $EmailSubjects['en'] | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose 'EmailSubject: Not a valid JSON object found, interpreting as raw text' | |
| } | |
| } | |
| $templateVariables.emailSubject = Expand-Template -Template $EmailSubject -Variables $templateVariables | |
| if ([string]::IsNullOrEmpty($EmailTitle)) { | |
| if ($languageCode -eq 'de') { | |
| $EmailTitle = 'Befristeter Zugriffspass für die Einarbeitung' | |
| } | |
| else { | |
| $EmailTitle = 'Onboarding Temporary Access Pass Code' | |
| } | |
| } | |
| else { | |
| try { | |
| $EmailTitles = $EmailTitle | ConvertFrom-Json -AsHashtable -ErrorAction Stop | |
| if ($EmailTitles -is [hashtable]) { | |
| if ($EmailTitles.ContainsKey($languageCode)) { | |
| $EmailTitle = $EmailTitles[$languageCode] | |
| } | |
| elseif ($EmailTitles.ContainsKey('en')) { | |
| $EmailTitle = $EmailTitles['en'] | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose 'EmailTitle: Not a valid JSON object found, interpreting as raw text' | |
| } | |
| } | |
| $templateVariables.emailTitle = Expand-Template -Template $EmailTitle -Variables $templateVariables | |
| if ([string]::IsNullOrEmpty($EmailSalutation)) { | |
| if ($languageCode -eq 'de') { | |
| $EmailSalutation = 'Hallo {{ managerGivenName }} {{ managerSurname }},' | |
| } | |
| else { | |
| $EmailSalutation = 'Dear {{ managerGivenName }} {{ managerSurname }},' | |
| } | |
| } | |
| else { | |
| try { | |
| $EmailSalutations = $EmailSalutation | ConvertFrom-Json -AsHashtable -ErrorAction Stop | |
| if ($EmailSalutations -is [hashtable]) { | |
| if ($EmailSalutations.ContainsKey($languageCode)) { | |
| $EmailSalutation = $EmailSalutations[$languageCode] | |
| } | |
| elseif ($EmailSalutations.ContainsKey('en')) { | |
| $EmailSalutation = $EmailSalutations['en'] | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose 'EmailSalutation: Not a valid JSON object found, interpreting as raw text' | |
| } | |
| } | |
| $templateVariables.emailSalutation = Expand-Template -Template $EmailSalutation -Variables $templateVariables | |
| if ([string]::IsNullOrEmpty($EmailClosing)) { | |
| if ($languageCode -eq 'de') { | |
| $EmailClosing = 'Freundliche Grüße`nIhr {{ orgDisplayName }} IT-Team' | |
| } | |
| else { | |
| $EmailClosing = "Sincerely,`nYour {{ orgDisplayName }} IT Team" | |
| } | |
| } | |
| else { | |
| try { | |
| $EmailClosings = $EmailClosing | ConvertFrom-Json -AsHashtable -ErrorAction Stop | |
| if ($EmailClosings -is [hashtable]) { | |
| if ($EmailClosings.ContainsKey($languageCode)) { | |
| $EmailClosing = $EmailClosings[$languageCode] | |
| } | |
| elseif ($EmailClosings.ContainsKey('en')) { | |
| $EmailClosing = $EmailClosings['en'] | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose 'EmailClosing: Not a valid JSON object found, interpreting as raw text' | |
| } | |
| } | |
| $templateVariables.emailClosing = Expand-Template -Template $EmailClosing -Variables $templateVariables | |
| if ($UseHtmlEmail) { | |
| # Replace newlines with HTML line breaks | |
| $templateVariables.emailSubject = $templateVariables.emailSubject -replace "`n", "<br>" | |
| $templateVariables.emailTitle = $templateVariables.emailTitle -replace "`n", "<br>" | |
| $templateVariables.emailSalutation = $templateVariables.emailSalutation -replace "`n", "<br>" | |
| $templateVariables.emailClosing = $templateVariables.emailClosing -replace "`n", "<br>" | |
| # Get tenant branding images | |
| $tenantBranding = Get-TenantBrandingImages -TenantId $organizationInfo.Id | |
| if ($tenantBranding -and $tenantBranding.Count -gt 0) { | |
| Write-Verbose "Retrieved tenant branding with $($tenantBranding.Count) images" | |
| # Add tenant branding images to the EmailImages collection | |
| foreach ($key in $tenantBranding.Keys) { | |
| if (-not $EmailImages.ContainsKey($key)) { | |
| $EmailImages[$key] = $tenantBranding[$key] | |
| Write-Verbose "Added tenant branding image: $key" | |
| } | |
| } | |
| } | |
| } | |
| # Add all image references to template variables | |
| if ($EmailImages.Count -gt 0) { | |
| # First add all possible image references to template variables | |
| foreach ($imageKey in $EmailImages.Keys) { | |
| $templateVariables["image:$imageKey"] = "cid:$imageKey" | |
| } | |
| } | |
| # Determine which email template to use | |
| if ($UseHtmlEmail) { | |
| $bodyContentType = 'HTML' | |
| if ([string]::IsNullOrEmpty($EmailTemplate)) { | |
| if ($languageCode -eq 'de') { | |
| Write-Verbose 'Using default German HTML email template' | |
| $bodyContentTemplate = @" | |
| <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | |
| <html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office"> | |
| <head> | |
| <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta name="x-apple-disable-message-reformatting" /> | |
| <!--[if mso]> | |
| <xml> | |
| <o:OfficeDocumentSettings> | |
| <o:AllowPNG/> | |
| <o:PixelsPerInch>96</o:PixelsPerInch> | |
| </o:OfficeDocumentSettings> | |
| </xml> | |
| <![endif]--> | |
| <style type="text/css"> | |
| /* Client-specific styles */ | |
| #outlook a{padding:0;} | |
| .ReadMsgBody{width:100%;} | |
| .ExternalClass{width:100%;} | |
| .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div{line-height: 100%;} | |
| <!--[if mso]> | |
| body, table, td, div, p { | |
| font-family: Arial, sans-serif !important; | |
| } | |
| table { | |
| border-collapse: collapse !important; | |
| mso-table-lspace: 0pt !important; | |
| mso-table-rspace: 0pt !important; | |
| } | |
| <![endif]--> | |
| /* Reset styles */ | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| border: 0; | |
| } | |
| /* Base styles */ | |
| body { | |
| font-family: Arial, sans-serif !important; | |
| margin: 0; | |
| padding: 0; | |
| color: #333; | |
| } | |
| /* Security alert banner */ | |
| .security-alert { | |
| background-color: #FFF4B5; | |
| border-bottom: 1px solid #e6c949; | |
| color: #222222; | |
| font-family: Arial, sans-serif; | |
| font-size: 12px; | |
| font-weight: normal; | |
| width: 100%; | |
| margin: 0 0 22px 0; | |
| padding: 8px 0; | |
| text-align: center; | |
| } | |
| .security-alert img { | |
| max-height: 16px; | |
| vertical-align: middle; | |
| margin-right: 5px; | |
| } | |
| /* Layout styles */ | |
| .page-wrapper { | |
| max-width: 600px; | |
| margin: 0 auto; | |
| } | |
| .logo-wrapper { | |
| text-align: right; | |
| margin-bottom: 15px; | |
| } | |
| .logo { | |
| max-height: 25px; | |
| } | |
| .container { | |
| background-color: #f9f9f9; | |
| border: 1px solid #dddddd; | |
| border-radius: 5px; | |
| padding: 20px; | |
| } | |
| .header { | |
| border-bottom: 1px solid #eeeeee; | |
| padding-bottom: 10px; | |
| margin-bottom: 20px; | |
| } | |
| h2 { | |
| margin-top: 0; | |
| margin-bottom: 15px; | |
| } | |
| .content { | |
| margin-bottom: 20px; | |
| } | |
| /* User info styles */ | |
| .user-info { | |
| text-align: center; | |
| margin: 15px auto; | |
| padding: 10px; | |
| background-color: #f0f0f0; | |
| border-radius: 4px; | |
| max-width: 80%; | |
| } | |
| .user-info p { | |
| text-align: center; | |
| margin: 5px 0; | |
| } | |
| /* Code display styles */ | |
| .code { | |
| background-color: #e0e0e0; | |
| padding: 15px; | |
| font-family: monospace; | |
| font-size: 18px; | |
| font-weight: bold; | |
| text-align: center; | |
| letter-spacing: 2px; | |
| border-radius: 5px; | |
| margin: 15px 0; | |
| border: 1px solid #ccc; | |
| } | |
| .expire-time { | |
| color: #cc0000; | |
| font-weight: 500; | |
| } | |
| /* Footer styles */ | |
| .footer { | |
| padding-top: 10px; | |
| margin-top: 20px; | |
| color: #777777; | |
| } | |
| .security-note { | |
| font-size: 11px; | |
| color: #666; | |
| margin-top: 20px; | |
| border-top: 1px dotted #ddd; | |
| padding-top: 10px; | |
| } | |
| /* Legal footer styles */ | |
| .legal-wrapper { | |
| text-align: center; | |
| margin-top: 15px; | |
| margin-bottom: 15px; | |
| font-size: 10px; | |
| color: #666666; | |
| width: 100%; | |
| } | |
| .legal-logo { | |
| max-height: 15px; | |
| margin: 0 auto; | |
| display: block; | |
| } | |
| .legal-wrapper p { | |
| margin: 5px 0; | |
| text-align: center; | |
| } | |
| .legal-wrapper a { | |
| color: #666666; | |
| text-decoration: none; | |
| } | |
| /* Typography styles */ | |
| p { | |
| margin: 10px 0; | |
| } | |
| a { | |
| color: #0066cc; | |
| text-decoration: underline; | |
| font-weight: 500; | |
| } | |
| a:hover { | |
| text-decoration: none; | |
| } | |
| ol { | |
| padding-left: 25px; | |
| } | |
| li { | |
| margin-bottom: 8px; | |
| } | |
| /* Mobile styles */ | |
| @media screen and (max-width: 480px) { | |
| .container { | |
| padding: 15px 10px; | |
| } | |
| .code { | |
| font-size: 14px; | |
| word-break: break-all; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body style="margin:0; padding:0; background-color:#ffffff; font-family:Arial, sans-serif;"> | |
| <!-- Outer wrapper to prevent zoom-to-fit --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%; min-width:600px;"> | |
| <tr> | |
| <td align="center"> | |
| <!-- Security alert banner --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#FFF4B5" style="min-width:600px; background-color:#FFF4B5; border-bottom:1px solid #e6c949;"> | |
| <tr> | |
| <td align="center" style="padding:8px 0; font-family:Arial, sans-serif; font-size:13px; color:#222222;"> | |
| <img src="{{ image:iconWarning }}" alt="Warning" width="16" height="16" style="vertical-align:middle; margin-right:5px;" /> | |
| <strong>Sicherheitshinweis:</strong> Diese Nachricht enthält sensible Kontoinformationen. Bitte sorgfältig behandeln. | |
| </td> | |
| </tr> | |
| </table> | |
| <!-- Main content wrapper --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="min-width:600px;"> | |
| <tr> | |
| <td style="padding:20px;"> | |
| <!-- Content container --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" align="center" style="max-width:600px;"> | |
| <tr> | |
| <!-- Logo row --> | |
| <td align="right" style="padding-bottom:15px;"> | |
| <img src="{{ image:tenantBannerLogo }}" alt="Company Logo" style="max-height:25px;" /> | |
| </td> | |
| </tr> | |
| <tr> | |
| <!-- Main container --> | |
| <td style="background-color:#f9f9f9; border:1px solid #dddddd; border-radius:5px; padding:20px;"> | |
| <!-- Header section --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="border-bottom:1px solid #eeeeee; padding-bottom:10px; margin-bottom:20px;"> | |
| <h2 style="margin-top:0; margin-bottom:15px; font-family:Arial, sans-serif;">{{ emailTitle }}</h2> | |
| </td> | |
| </tr> | |
| </table> | |
| <!-- Content section --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="padding-top:10px;"> | |
| <p style="margin:10px 0;">{{ emailSalutation }}</p> | |
| <p style="margin:10px 0;">Ein befristeter Zugriffspass für die Einarbeitung wurde für die folgende Person erstellt:</p> | |
| </td> | |
| </tr> | |
| <!-- User info section --> | |
| <tr> | |
| <td align="center" style="padding:15px 0;"> | |
| <table role="presentation" border="0" cellspacing="0" cellpadding="10" style="margin:0 auto;" width="80%"> | |
| <tr> | |
| <td align="center"> | |
| <p style="margin:5px 0;"><strong>{{ userDisplayName }}</strong></p> | |
| <p style="margin:5px 0;">{{ userPrincipalName }}</p> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| <!-- Code section --> | |
| <tr> | |
| <td> | |
| <p style="margin:10px 0;">Bitte geben Sie den folgenden Code an {{ userGivenName }} weiter:</p> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="15" bgcolor="#e0e0e0" style="background-color:#e0e0e0; margin:15px 0; border:1px solid #ccc; border-radius:5px;"> | |
| <tr> | |
| <td align="center" style="font-family:monospace; font-size:18px; font-weight:bold; letter-spacing:2px;"> | |
| {{ temporaryAccessPass }} | |
| </td> | |
| </tr> | |
| </table> | |
| <p style="margin:10px 0;">Der Code läuft in <strong>{{ lifeTimeInHoursMinutes }}</strong> um <span style="color:#cc0000; font-weight:500;">{{ expirationTime }} (GMT)</span> ab.</p> | |
| <p style="margin:10px 0;">Nächste Schritte für {{ userGivenName }}:</p> | |
| <ol style="padding-left:25px; margin-top:10px;"> | |
| <li style="margin-bottom:8px;">Verwenden Sie den Code, um sich anzumelden, wenn Sie dazu aufgefordert werden.</li> | |
| <li style="margin-bottom:8px;">Richten Sie die Microsoft Authenticator-App oder eine andere Authentifizierungsmethode ein.</li> | |
| <li style="margin-bottom:8px;">Nach Abschluss der Einrichtung kann der befristete Zugriffspass unter <a href="https://aka.ms/MySecurityInfo" style="color:#0066cc; text-decoration:underline; font-weight:500;">https://aka.ms/MySecurityInfo</a> gelöscht werden.</li> | |
| </ol> | |
| </td> | |
| </tr> | |
| </table> | |
| <!-- Footer section --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="padding-top:20px; margin-top:20px; border-top:1px solid #eeeeee; color:#777777;"> | |
| <p style="margin:10px 0;">Vielen Dank für Ihre Unterstützung.</p> | |
| <p style="margin:10px 0;">{{ emailClosing }}</p> | |
| <p style="margin:20px 0; font-style:italic;">Hinweis: Wenn Sie die Anfrage nicht gestellt haben, können Sie diese E-Mail ohne Bedenken ignorieren.</p> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="padding-top:10px; margin-top:20px; border-top:1px dotted #ddd; font-size:11px; color:#666;"> | |
| <p style="margin:5px 0;">Diese E-Mail wurde automatisch erstellt und enthält einen vorübergehenden Zugriffspass. Aus Sicherheitsgründen läuft der Code nach der Verwendung oder nach der oben angegebenen Zeit ab.</p> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| <!-- Legal footer outside container --> | |
| <tr> | |
| <td align="center" style="padding-top:15px; font-size:10px; color:#666666;"> | |
| <img src="{{ image:tenantBannerLogo }}" alt="Company Logo" style="max-height:15px; margin:0 auto; display:block;" /> | |
| <p style="margin:5px 0; text-align:center;">{{ orgStreet }}, {{ orgPostalCode }} {{ orgCity }}, {{ orgCountry }}</p> | |
| <p style="margin:5px 0; text-align:center;"> | |
| <a href="{{ orgPrivacyStatementUrl }}" style="color:#666666; text-decoration:none;">Datenschutzerklärung</a> | | |
| <a href="mailto:{{ orgPrivacyContact }}" style="color:#666666; text-decoration:none;">Datenschutzkontakt</a> | |
| </p> | |
| <p style="margin:5px 0; text-align:center;">Mandanten-ID: {{ orgTenantId }}</p> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </body> | |
| </html> | |
| "@ | |
| } | |
| else { | |
| Write-Verbose 'Using default English HTML email template' | |
| $bodyContentTemplate = @" | |
| <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | |
| <html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office"> | |
| <head> | |
| <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <meta name="x-apple-disable-message-reformatting" /> | |
| <!--[if mso]> | |
| <xml> | |
| <o:OfficeDocumentSettings> | |
| <o:AllowPNG/> | |
| <o:PixelsPerInch>96</o:PixelsPerInch> | |
| </o:OfficeDocumentSettings> | |
| </xml> | |
| <![endif]--> | |
| <style type="text/css"> | |
| /* Client-specific styles */ | |
| #outlook a{padding:0;} | |
| .ReadMsgBody{width:100%;} | |
| .ExternalClass{width:100%;} | |
| .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div{line-height: 100%;} | |
| <!--[if mso]> | |
| body, table, td, div, p { | |
| font-family: Arial, sans-serif !important; | |
| } | |
| table { | |
| border-collapse: collapse !important; | |
| mso-table-lspace: 0pt !important; | |
| mso-table-rspace: 0pt !important; | |
| } | |
| <![endif]--> | |
| /* Reset styles */ | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| border: 0; | |
| } | |
| /* Base styles */ | |
| body { | |
| font-family: Arial, sans-serif !important; | |
| margin: 0; | |
| padding: 0; | |
| color: #333; | |
| } | |
| /* Security alert banner */ | |
| .security-alert { | |
| background-color: #FFF4B5; | |
| border-bottom: 1px solid #e6c949; | |
| color: #222222; | |
| font-family: Arial, sans-serif; | |
| font-size: 12px; | |
| font-weight: normal; | |
| width: 100%; | |
| margin: 0 0 22px 0; | |
| padding: 8px 0; | |
| text-align: center; | |
| } | |
| .security-alert img { | |
| max-height: 16px; | |
| vertical-align: middle; | |
| margin-right: 5px; | |
| } | |
| /* Layout styles */ | |
| .page-wrapper { | |
| max-width: 600px; | |
| margin: 0 auto; | |
| } | |
| .logo-wrapper { | |
| text-align: right; | |
| margin-bottom: 15px; | |
| } | |
| .logo { | |
| max-height: 25px; | |
| } | |
| .container { | |
| background-color: #f9f9f9; | |
| border: 1px solid #dddddd; | |
| border-radius: 5px; | |
| padding: 20px; | |
| } | |
| .header { | |
| border-bottom: 1px solid #eeeeee; | |
| padding-bottom: 10px; | |
| margin-bottom: 20px; | |
| } | |
| h2 { | |
| margin-top: 0; | |
| margin-bottom: 15px; | |
| } | |
| .content { | |
| margin-bottom: 20px; | |
| } | |
| /* User info styles */ | |
| .user-info { | |
| text-align: center; | |
| margin: 15px auto; | |
| padding: 10px; | |
| background-color: #f0f0f0; | |
| border-radius: 4px; | |
| max-width: 80%; | |
| } | |
| .user-info p { | |
| text-align: center; | |
| margin: 5px 0; | |
| } | |
| /* Code display styles */ | |
| .code { | |
| background-color: #e0e0e0; | |
| padding: 15px; | |
| font-family: monospace; | |
| font-size: 18px; | |
| font-weight: bold; | |
| text-align: center; | |
| letter-spacing: 2px; | |
| border-radius: 5px; | |
| margin: 15px 0; | |
| border: 1px solid #ccc; | |
| } | |
| .expire-time { | |
| color: #cc0000; | |
| font-weight: 500; | |
| } | |
| /* Footer styles */ | |
| .footer { | |
| padding-top: 10px; | |
| margin-top: 20px; | |
| color: #777777; | |
| } | |
| .security-note { | |
| font-size: 11px; | |
| color: #666; | |
| margin-top: 20px; | |
| border-top: 1px dotted #ddd; | |
| padding-top: 10px; | |
| } | |
| /* Legal footer styles */ | |
| .legal-wrapper { | |
| text-align: center; | |
| margin-top: 15px; | |
| margin-bottom: 15px; | |
| font-size: 10px; | |
| color: #666666; | |
| width: 100%; | |
| } | |
| .legal-logo { | |
| max-height: 15px; | |
| margin: 0 auto; | |
| display: block; | |
| } | |
| .legal-wrapper p { | |
| margin: 5px 0; | |
| text-align: center; | |
| } | |
| .legal-wrapper a { | |
| color: #666666; | |
| text-decoration: none; | |
| } | |
| /* Typography styles */ | |
| p { | |
| margin: 10px 0; | |
| } | |
| a { | |
| color: #0066cc; | |
| text-decoration: underline; | |
| font-weight: 500; | |
| } | |
| a:hover { | |
| text-decoration: none; | |
| } | |
| ol { | |
| padding-left: 25px; | |
| } | |
| li { | |
| margin-bottom: 8px; | |
| } | |
| /* Mobile styles */ | |
| @media screen and (max-width: 480px) { | |
| .container { | |
| padding: 15px 10px; | |
| } | |
| .code { | |
| font-size: 14px; | |
| word-break: break-all; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body style="margin:0; padding:0; background-color:#ffffff; font-family:Arial, sans-serif;"> | |
| <!-- Outer wrapper to prevent zoom-to-fit --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%; min-width:600px;"> | |
| <tr> | |
| <td align="center"> | |
| <!-- Security alert banner --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#FFF4B5" style="min-width:600px; background-color:#FFF4B5; border-bottom:1px solid #e6c949;"> | |
| <tr> | |
| <td align="center" style="padding:8px 0; font-family:Arial, sans-serif; font-size:13px; color:#222222;"> | |
| <img src="{{ image:iconWarning }}" alt="Warning" width="16" height="16" style="vertical-align:middle; margin-right:5px;" /> | |
| <strong>Security notice:</strong> This message contains sensitive account information. Please handle with care. | |
| </td> | |
| </tr> | |
| </table> | |
| <!-- Main content wrapper --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="min-width:600px;"> | |
| <tr> | |
| <td style="padding:20px;"> | |
| <!-- Content container --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" align="center" style="max-width:600px;"> | |
| <tr> | |
| <!-- Logo row --> | |
| <td align="right" style="padding-bottom:15px;"> | |
| <img src="{{ image:tenantBannerLogo }}" alt="Company Logo" style="max-height:25px;" /> | |
| </td> | |
| </tr> | |
| <tr> | |
| <!-- Main container --> | |
| <td style="background-color:#f9f9f9; border:1px solid #dddddd; border-radius:5px; padding:20px;"> | |
| <!-- Header section --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="border-bottom:1px solid #eeeeee; padding-bottom:10px; margin-bottom:20px;"> | |
| <h2 style="margin-top:0; margin-bottom:15px; font-family:Arial, sans-serif;">{{ emailTitle }}</h2> | |
| </td> | |
| </tr> | |
| </table> | |
| <!-- Content section --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="padding-top:10px;"> | |
| <p style="margin:10px 0;">{{ emailSalutation }}</p> | |
| <p style="margin:10px 0;">A Temporary Access Pass code for onboarding has been created for the following person:</p> | |
| </td> | |
| </tr> | |
| <!-- User info section --> | |
| <tr> | |
| <td align="center" style="padding:15px 0;"> | |
| <table role="presentation" border="0" cellspacing="0" cellpadding="10" style="margin:0 auto;" width="80%"> | |
| <tr> | |
| <td align="center"> | |
| <p style="margin:5px 0;"><strong>{{ userDisplayName }}</strong></p> | |
| <p style="margin:5px 0;">{{ userPrincipalName }}</p> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| <!-- Code section --> | |
| <tr> | |
| <td> | |
| <p style="margin:10px 0;">Please provide the following code to {{ userGivenName }}:</p> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="15" bgcolor="#e0e0e0" style="background-color:#e0e0e0; margin:15px 0; border:1px solid #ccc; border-radius:5px;"> | |
| <tr> | |
| <td align="center" style="font-family:monospace; font-size:18px; font-weight:bold; letter-spacing:2px;"> | |
| {{ temporaryAccessPass }} | |
| </td> | |
| </tr> | |
| </table> | |
| <p style="margin:10px 0;">The code will expire in <strong>{{ lifeTimeInHoursMinutes }}</strong>, at <span style="color:#cc0000; font-weight:500;">{{ expirationTime }} UTC</span>.</p> | |
| <p style="margin:10px 0;">Next steps for {{ userGivenName }}:</p> | |
| <ol style="padding-left:25px; margin-top:10px;"> | |
| <li style="margin-bottom:8px;">Use the code to sign in when prompted.</li> | |
| <li style="margin-bottom:8px;">Set up the Microsoft Authenticator app or another authentication method.</li> | |
| <li style="margin-bottom:8px;">After setup is complete, the Temporary Access Pass can be deleted at <a href="https://aka.ms/MySecurityInfo" style="color:#0066cc; text-decoration:underline; font-weight:500;">https://aka.ms/MySecurityInfo</a> .</li> | |
| </ol> | |
| </td> | |
| </tr> | |
| </table> | |
| <!-- Footer section --> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="padding-top:20px; margin-top:20px; border-top:1px solid #eeeeee; color:#777777;"> | |
| <p style="margin:10px 0;">Thank you for your assistance.</p> | |
| <p style="margin:10px 0;">{{ emailClosing }}</p> | |
| <p style="margin:20px 0; font-style:italic;">Note: If you have not made the request, you can safely ignore this e-mail.</p> | |
| <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"> | |
| <tr> | |
| <td style="padding-top:10px; margin-top:20px; border-top:1px dotted #ddd; font-size:11px; color:#666;"> | |
| <p style="margin:5px 0;">This is an automated email containing a secure access code. For security reasons, the code will expire after use or after the time indicated above.</p> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| <!-- Legal footer outside container --> | |
| <tr> | |
| <td align="center" style="padding-top:15px; font-size:10px; color:#666666;"> | |
| <img src="{{ image:tenantBannerLogo }}" alt="Company Logo" style="max-height:15px; margin:0 auto; display:block;" /> | |
| <p style="margin:5px 0; text-align:center;">{{ orgStreet }}, {{ orgPostalCode }} {{ orgCity }}, {{ orgCountry }}</p> | |
| <p style="margin:5px 0; text-align:center;"> | |
| <a href="{{ orgPrivacyStatementUrl }}" style="color:#666666; text-decoration:none;">Privacy Statement</a> | | |
| <a href="mailto:{{ orgPrivacyContact }}" style="color:#666666; text-decoration:none;">Privacy Contact</a> | |
| </p> | |
| <p style="margin:5px 0; text-align:center;">Tenant ID: {{ orgTenantId }}</p> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </td> | |
| </tr> | |
| </table> | |
| </body> | |
| </html> | |
| "@ | |
| } | |
| } | |
| else { | |
| try { | |
| $EmailTemplates = $EmailTemplate | ConvertFrom-Json -AsHashtable -ErrorAction Stop | |
| if ($EmailTemplates -is [hashtable]) { | |
| if ($EmailTemplates.ContainsKey($languageCode)) { | |
| Write-Verbose "Using custom language-specific HTML email template for $languageCode" | |
| $bodyContentTemplate = $EmailTemplates[$languageCode] | |
| } | |
| elseif ($EmailClosings.ContainsKey('en')) { | |
| Write-Verbose 'Using custom default English HTML email template' | |
| $bodyContentTemplate = $EmailTemplates['en'] | |
| } | |
| else { | |
| throw 'No default English template found' | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose 'EmailTemplate: Not a valid JSON object found, interpreting as raw HTML' | |
| $bodyContentTemplate = $EmailTemplate | |
| } | |
| } | |
| } | |
| else { | |
| $bodyContentType = 'Text' | |
| if ([string]::IsNullOrEmpty($EmailTemplate)) { | |
| if ($languageCode -eq 'de') { | |
| $bodyContentTemplate = @" | |
| {{ emailSalutation }} | |
| Ein befristeter Zugriffscode für die Einarbeitung wurde für die folgende Person erstellt: | |
| {{ userDisplayName }} | |
| {{ userPrincipalName }} | |
| Bitte geben Sie den folgenden Code an {{ userGivenName }} weiter: | |
| {{ temporaryAccessPass }} | |
| Der Code läuft in {{ lifeTimeInHoursMinutes }} um {{ expirationTime }} (GMT) ab. | |
| Nächste Schritte für {{ userGivenName }}: | |
| 1. Verwenden Sie den Code, um sich anzumelden, wenn Sie dazu aufgefordert werden. | |
| 2. Richten Sie die Microsoft Authenticator-App oder eine andere Authentifizierungsmethode ein. | |
| 3. Nach Abschluss der Einrichtung kann der befristete Zugriffspass unter https://aka.ms/MySecurityInfo gelöscht werden. | |
| Vielen Dank für Ihre Unterstützung. | |
| {{ emailClosing }} | |
| Hinweis: Wenn Sie die Anfrage nicht gestellt haben, können Sie diese E-Mail ohne Bedenken ignorieren. | |
| -- | |
| Diese E-Mail wurde automatisch erstellt und enthält einen vorübergehenden Zugriffspass. Aus Sicherheitsgründen läuft der Code nach der Verwendung oder nach der oben angegebenen Zeit ab. | |
| {{ orgDisplayName }}, {{ orgStreet }}, {{ orgPostalCode }} {{ orgCity }}, {{ orgCountry }} | |
| Mandanten-ID: {{ orgTenantId }} | |
| "@ | |
| } | |
| else { | |
| $bodyContentTemplate = @" | |
| {{ emailSalutation }} | |
| A Temporary Access Pass code for onboarding has been created for the following person: | |
| {{ userDisplayName }} | |
| {{ userPrincipalName }} | |
| Please provide the following code to {{ userGivenName }}: | |
| {{ temporaryAccessPass }} | |
| The code will expire in {{ lifeTimeInHoursMinutes }}, at {{ expirationTime }} UTC. | |
| Next steps for {{ userGivenName }}: | |
| 1. Use the code to sign in when prompted. | |
| 2. Set up the Microsoft Authenticator app or another authentication method. | |
| 3. After setup is complete, the Temporary Access Pass can be deleted at https://aka.ms/MySecurityInfo . | |
| Thank you for your assistance. | |
| {{ emailClosing }} | |
| Note: If you have not made the request, you can safely ignore this e-mail. | |
| -- | |
| This is an automated email containing a temporary access pass. For security reasons, the code will expire after use or after the time indicated above. | |
| {{ orgDisplayName }}, {{ orgStreet }}, {{ orgPostalCode }} {{ orgCity }}, {{ orgCountry }} | |
| Tenant ID: {{ orgTenantId }} | |
| "@ | |
| } | |
| } | |
| else { | |
| try { | |
| $EmailTemplates = $EmailTemplate | ConvertFrom-Json -AsHashtable -ErrorAction Stop | |
| if ($EmailTemplates -is [hashtable]) { | |
| if ($EmailTemplates.ContainsKey($languageCode)) { | |
| $bodyContentTemplate = $EmailTemplates[$languageCode] | |
| } | |
| elseif ($EmailClosings.ContainsKey('en')) { | |
| $bodyContentTemplate = $EmailTemplates['en'] | |
| } | |
| else { | |
| throw 'No default English template found' | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Verbose 'EmailTemplate: Not a valid JSON object found, interpreting as raw text' | |
| $bodyContentTemplate = $EmailTemplate | |
| } | |
| } | |
| } | |
| # Expand template with variables | |
| $bodyContent = Expand-Template -Template $bodyContentTemplate -Variables $templateVariables | |
| # Extract all image references using regex to find actually used images | |
| if ($EmailImages.Count -gt 0 -and $UseHtmlEmail) { | |
| $usedImageKeys = @() | |
| # Find all occurrences of "cid:imageName" in the expanded template | |
| $cidMatches = [regex]::Matches($bodyContent, 'cid:([a-zA-Z0-9_\-]+)') | |
| foreach ($match in $cidMatches) { | |
| if ($match.Groups.Count -gt 1) { | |
| $usedImageKeys += $match.Groups[1].Value | |
| } | |
| } | |
| Write-Verbose "Found $($usedImageKeys.Count) image references in template" | |
| # Only process images that are actually used in the template | |
| $emailAttachments = @() | |
| foreach ($imageKey in $EmailImages.Keys) { | |
| if ($imageKey -in $usedImageKeys) { | |
| $imageBase64 = $EmailImages[$imageKey] | |
| # Validate the base64 data | |
| try { | |
| # Handle both clean base64 and data URLs | |
| if ($imageBase64 -match '^data:(.*?);base64,(.+)$') { | |
| $contentType = $Matches[1] | |
| $cleanBase64 = $Matches[2] | |
| } | |
| else { | |
| # Assume it's plain base64 | |
| $contentType = 'image/png' # Default content type | |
| $cleanBase64 = $imageBase64 | |
| } | |
| # Validate base64 string | |
| $null = [Convert]::FromBase64String($cleanBase64) | |
| $emailAttachments += @{ | |
| '@odata.type' = '#microsoft.graph.fileAttachment' | |
| name = "$imageKey" | |
| contentType = $contentType | |
| contentBytes = $cleanBase64 | |
| contentId = $imageKey | |
| isInline = $true | |
| } | |
| Write-Verbose "Added image attachment: $imageKey ($contentType)" | |
| } | |
| catch { | |
| Write-Warning "Invalid base64 image data for key '$imageKey': $_" | |
| } | |
| } | |
| } | |
| } | |
| # Construct email payload for Graph API | |
| $emailContent = @{ | |
| message = @{ | |
| subject = $templateVariables.emailSubject | |
| body = @{ | |
| contentType = $bodyContentType | |
| content = $bodyContent | |
| } | |
| toRecipients = @( | |
| @{ | |
| emailAddress = @{ | |
| address = $templateVariables.managerMail | |
| } | |
| } | |
| ) | |
| internetMessageHeaders = @( | |
| @{ | |
| name = 'X-Classification' | |
| value = 'Confidential' | |
| } | |
| @{ | |
| name = 'X-Exchange-Restrict' | |
| value = 'InternalOnly' | |
| } | |
| @{ | |
| name = 'X-No-Archive' | |
| value = 'Yes' | |
| } | |
| @{ | |
| name = 'X-Protect-Delivery' | |
| value = 'Secure' | |
| } | |
| @{ | |
| name = 'X-Sensitivity' | |
| value = 'Company-Confidential' | |
| } | |
| ) | |
| importance = "high" | |
| } | |
| saveToSentItems = "false" | |
| } | |
| # Add attachments only if we have any | |
| if ($emailAttachments.Count -gt 0) { | |
| $emailContent.message.attachments = $emailAttachments | |
| Write-Verbose "Added $($emailAttachments.Count) attachments to email" | |
| } | |
| if ($PSCmdlet.ShouldProcess( | |
| "Send Temporary Access Pass email to $($templateVariables.managerMail) from $SenderEmailAddress", | |
| "Do you want to send the TAP code for $($return.Data.DisplayName) to their manager at $($templateVariables.managerMail)?", | |
| 'Send Email with Temporary Access Pass' | |
| )) { | |
| try { | |
| $null = Invoke-ResilientRemoteCall { | |
| if (-not $WhatIfPreference) { | |
| Invoke-MgGraphRequest -Method POST ` | |
| -Uri "/v1.0/users/$SenderEmailAddress/sendMail" ` | |
| -Body ($emailContent | ConvertTo-Json -Depth 4) ` | |
| -ErrorAction Stop | |
| Write-Output "Temporary Access Pass code sent to manager $($templateVariables.managerMail) from $SenderEmailAddress" | |
| } | |
| else { | |
| Write-Verbose "What If: Would send email from $SenderEmailAddress to $($templateVariables.managerMail) with subject 'Temporary Access Pass for $($return.Data.DisplayName)'" | |
| } | |
| } | |
| } | |
| catch { | |
| # delete the TAP if email sending fails | |
| try { | |
| $null = Invoke-ResilientRemoteCall { | |
| if (-not $WhatIfPreference) { | |
| Invoke-MgGraphRequest -Method DELETE ` | |
| -Uri "/v1.0/users/$($userObj.id)/authentication/temporaryAccessPassMethods/$($return.Data.TemporaryAccessPass.Id)" ` | |
| -ErrorAction Stop | |
| } | |
| } | |
| } | |
| catch { | |
| Write-Error "Failed to delete TAP after email sending failed: $_" | |
| } | |
| throw "Failed to send Temporary Access Pass code to manager $($templateVariables.managerMail): $_" | |
| } | |
| } | |
| else { | |
| Write-Verbose "Email sending was canceled by user" | |
| } | |
| return | |
| } | |
| # Return results | |
| if ($return.Data.PSObject.Properties.Count -eq 0) { | |
| $return.Remove('Data') | |
| } | |
| if ($OutText) { | |
| return $return.Data?.TemporaryAccessPass?.temporaryAccessPass ?? $null | |
| } | |
| if ($OutJson) { | |
| return $return | ConvertTo-Json -Depth 4 | |
| } | |
| return $return | 
  
    Sign up for free
    to join this conversation on GitHub.
    Already have an account?
    Sign in to comment