#Requires -Version 7.0 # PIM Manager 3.0.70 -- Set-PimSmtpRelayPassword.ps1, standalone (built by tools/setup/Build-PimSupportScripts.ps1; helpers inlined: _pimmailsetup.ps1, _pimloopbackanswer.ps1, _pimscriptdoc.ps1) <# .SYNOPSIS Put the SMTP relay password of a PIM Manager environment into its Key Vault, and let the environment's managed identities read that one secret. -WhatIf shows every change first and asks for no password. .DESCRIPTION Owner 2026-10-08: "remember i need to have 2 options: shared mailbox or smtp relay solution" / "we dont use certificates here, either interactive login or secret". In SMTP relay mode PIM Manager sends through System.Net.Mail with a user name and a password. 🔒 The password is NEVER in SQL, in the page or in a log: it is a secret in the environment's own Key Vault, read at send time by the environment's managed identities. PIM Manager's Settings > Mail & alerting writes it itself when its identity may; otherwise this script does, as a person who may (or as the Invardia Support app): 1. finds the vault in -SubscriptionId (or, with -CreateVault + -ResourceGroup, creates it: Azure RBAC authorisation); 2. writes the secret (-SecretName, default PIM-SmtpRelayPassword) -- when Key Vault refuses the writer, it grants the writer 'Key Vault Secrets Officer' on THAT VAULT once and retries (the RBAC change can take a few minutes); 3. grants every -ManagedIdentityObjectId (the Manager's and the engine job's) 'Key Vault Secrets User' on THAT SECRET only -- never the whole vault; 4. reads the secret back and compares it (the value is never printed). Sign-in: in the BROWSER by default (Edge, auth code + PKCE, no device code, no module), or the Invardia Support app (-AdminAppId + -AdminSecret). No certificate. The password comes from -Password (a SecureString) or a prompt (typed twice) -- never as plain text on the command line. -WhatIf signs in, reads and prints every change it would make ("What if: ..."), asks for no password and writes nothing (no secret, no role assignment, no vault, no -OutFile). Published standalone at https://invardia.com/support/pim/Set-PimSmtpRelayPassword.ps1 (Build-PimSupportScripts.ps1). .EXAMPLE .\Set-PimSmtpRelayPassword.ps1 -TenantId -SubscriptionId -VaultName ` -ManagedIdentityObjectId , -WhatIf Preview; then the same command without -WhatIf (it asks for the password twice). .NOTES Requirements: PowerShell: 7.0 or later (pwsh). Windows PowerShell 5.1 is not supported. Role: Key Vault Secrets Officer and User Access Administrator (or Owner) on the environment's Key Vault; Reader on the subscription; Contributor on the resource group only with -CreateVault (Azure). Hosts: login.microsoftonline.com, management.azure.com, *.vault.azure.net PERMISSIONS AND CHANGES (from Set-PimSmtpRelayPassword.ps1 manifest, PIM Manager 3.0.70; full page: https://invardia.com/docs/pim/scripts/Set-PimSmtpRelayPassword/) The person running it needs: - Key Vault Secrets Officer [azureRbac, the environment's Key Vault] -- Writes the secret. When you can manage the vault's access (User Access Administrator or Owner) but not its secrets, the script grants you this role on that one vault and retries. - User Access Administrator [azureRbac, the environment's Key Vault (Owner or Role Based Access Control Administrator work too)] -- Gives each sending identity read access on the one secret, and, when needed, gives you Key Vault Secrets Officer on the vault. - Reader [azureRbac, the subscription (-SubscriptionId)] -- Finds the vault by name in the subscription. - Contributor [azureRbac, the resource group (-ResourceGroup), only with -CreateVault] -- Creates the Key Vault when it does not exist. - browser sign-in to Azure (first-party public client 'Microsoft Azure PowerShell'), or the Invardia Support app (-AdminAppId + -AdminSecret) [other, Azure Resource Manager and Key Vault in your tenant] -- No PowerShell module and no certificate. The Support app's secret is accepted only for Invardia support; a person signs in in the browser. What it creates, changes or grants: - create Key Vault -VaultName (Azure RBAC authorisation, standard SKU, soft delete 90 days, the resource group's region) -- only with -CreateVault and only when it does not exist (Azure Resource Manager) @ the resource group -ResourceGroup -- The relay password must live in the environment's own vault, never in SQL, the page or a log. Undo: Delete the Key Vault (it stays soft-deleted for 90 days; purge it to remove it at once). - create secret -SecretName (default PIM-SmtpRelayPassword) holding the SMTP relay password, content type 'PIM Manager SMTP relay password'; a new version when it exists. The value is read back to compare and never printed. (Azure Key Vault) @ the Key Vault -VaultName -- PIM Manager reads the password at send time with its managed identities. Undo: Delete the secret (Key Vault > Secrets), or set the previous version back. - grant Azure role assignment for the account running the script (Microsoft.Authorization/roleAssignments) -- ONLY when Key Vault refuses the secret write (Azure Resource Manager): Key Vault Secrets Officer @ the Key Vault -VaultName (that vault only) -- Someone who manages the vault's access but not its secrets can then write the secret without a second person. Undo: Key Vault > Access control (IAM) > remove your 'Key Vault Secrets Officer' assignment. - grant Azure role assignment for each -ManagedIdentityObjectId (the Manager's and the engine job's identities), with a fixed name so a re-run does not create a second one (Azure Resource Manager): Key Vault Secrets User @ the one secret only (/secrets/), never the whole vault -- The sending identities must read the password at send time, and nothing else in the vault. Undo: Key Vault > the secret > Access control (IAM) > remove the assignment. - create the result file -OutFile (JSON: vault, secret name, what was created and granted; never the password) -- only when -OutFile is given and not under -WhatIf (Local machine) @ the path you pass -- For automation that reads the outcome. Undo: Delete the file. -WhatIf: Signs in, finds the vault, asks for no password, and prints one "What if:" line per change: create the vault (with -CreateVault), write the secret, the Key Vault Secrets Officer grant to you (only if the write would be refused), and Key Vault Secrets User on the secret for each managed identity. Nothing is written, no -OutFile either. Undo: Remove the 'Key Vault Secrets User' assignments on the secret and any 'Key Vault Secrets Officer' assignment the script gave you on the vault, delete the secret, and delete the vault if the script created it. .LINK https://invardia.com/docs/pim/scripts/Set-PimSmtpRelayPassword/ #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$TenantId, [Parameter(Mandatory)][string]$SubscriptionId, [Parameter(Mandatory)][string]$VaultName, [string]$SecretName = 'PIM-SmtpRelayPassword', [string[]]$ManagedIdentityObjectId = @(), [System.Security.SecureString]$Password, # Create the vault when it does not exist (Azure RBAC authorisation, standard SKU, the resource group's region). [switch]$CreateVault, [string]$ResourceGroup, [string]$AdminAppId, [string]$AdminSecret, [string]$OutFile ) $ErrorActionPreference = 'Stop' # ===== BEGIN inlined helper: _PimMailSetup.ps1 ===== # _PimMailSetup.ps1 -- the pieces behind Initialize-PimMailSender.ps1 and Set-PimSmtpRelayPassword.ps1: which way the # script signs in, the BROWSER sign-in itself, and a per-API token cache. Dot-source it; it defines functions only (no # side effects on load). # # MAIL-1 (framework DOCS/REQUIREMENTS.md 12.3, owner 2026-10-08): "we dont use certificates here, either interactive login # or secret. not certificates". So the published scripts sign in in the BROWSER (an Exchange or Global Administrator) or # with the Invardia Support app's secret; a certificate stays accepted for old callers and is never what a page prints. # # NO MODULES (owner 2026-10-07: "neither pim, si or invardia must have dependencies"): plain Invoke-RestMethod + .NET. # Sign-in is auth code + PKCE on a localhost loopback, Microsoft Edge launched explicitly (the default browser when Edge # is missing) -- the pattern of _PimEnginePermissions.ps1 (Get-PimEpInteractiveToken), DUPLICATED here on purpose: each # published script is ONE standalone file (tools/setup/Build-PimSupportScripts.ps1 inlines this), and isolation beats # DRY between two scripts that ship separately. Never device code. # # The public clients (first-party, present in every tenant, no app registration needed): # * Exchange Online admin API -- "Microsoft Exchange REST API Based Powershell", the client the ExchangeOnlineManagement # V3 module itself signs in with; the token is for https://outlook.office365.com and drives the same # /adminapi/beta//InvokeCommand endpoint the module calls. # * Microsoft Graph -- "Microsoft Graph Command Line Tools" (as Grant-PimEnginePermissions.ps1). # * Azure Resource Manager, Azure SQL, Key Vault -- "Microsoft Azure PowerShell"; one sign-in, the other two from its # refresh token (the client is pre-authorised for all three). # ---- constants -------------------------------------------------------------------------------------------------------- function Get-PimMsExchangeClientId { 'fb78d390-0c51-40cd-8e17-fdbfab77341b' } # Microsoft Exchange REST API Based Powershell function Get-PimMsGraphCliClientId { '14d82eec-204b-4c2f-b7e8-296a70dab67e' } # Microsoft Graph Command Line Tools function Get-PimMsAzurePowerShellClientId { '1950a258-227b-4e31-a9cf-717495945fc2' } # Microsoft Azure PowerShell # The delegated Graph rights the shared-mailbox setup reads / changes with: the Exchange plan (subscribedSkus) and the # initial domain (organization), the service principals (managed identities, Graph), the mailbox's directory user, and # the removal of a tenant-wide Mail.Send consent (the scope rule of MAIL-1). function Get-PimMsGraphScopes { @('Organization.Read.All', 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All', 'User.Read.All') } # ---- which way to sign in (PURE) ---------------------------------------------------------------------------------------- function Resolve-PimMailSetupAuthMode { <# Decide how the setup script authenticates, BEFORE anything is touched. Returns @{ mode; reason } where mode is 'browser' | 'secret' | 'certificate' | 'signedIn' and a non-empty reason is a refusal: * nothing passed -> 'browser' (the person signs in; -AdminAppId is not needed) * -AdminSecret (+ -AdminAppId) -> 'secret' (the Invardia Support app) * -AdminCertThumbprint (+ -AdminAppId) -> 'certificate' (back-compat; never printed) * -UseSignedInAccount -> 'signedIn' (the Invardia Support session, else the browser) Refused: both a secret and a certificate; -UseSignedInAccount with a credential; a credential without -AdminAppId; -AdminAppId without a credential (ambiguous: was a secret forgotten?). #> param([string]$AdminAppId, [string]$AdminSecret, [string]$AdminCertThumbprint, [switch]$UseSignedInAccount) $app = "$AdminAppId".Trim(); $sec = "$AdminSecret".Trim(); $cert = "$AdminCertThumbprint".Trim() if ($sec -and $cert) { return @{ mode = ''; reason = 'pass EITHER -AdminSecret OR -AdminCertThumbprint, not both.' } } if ($UseSignedInAccount) { if ($sec -or $cert) { return @{ mode = ''; reason = '-UseSignedInAccount takes no -AdminSecret / -AdminCertThumbprint.' } } return @{ mode = 'signedIn'; reason = '' } } if ($sec -or $cert) { if (-not $app) { return @{ mode = ''; reason = '-AdminAppId is required with -AdminSecret / -AdminCertThumbprint (the app the credential belongs to).' } } return @{ mode = $(if ($sec) { 'secret' } else { 'certificate' }); reason = '' } } if ($app) { return @{ mode = ''; reason = "-AdminAppId $app was passed without its credential: add -AdminSecret (the Invardia Support app's secret), or leave -AdminAppId out to sign in in the browser." } } return @{ mode = 'browser'; reason = '' } } function Test-PimMsInteractiveHost { # A browser sign-in needs a person at this console. An unattended run (a deploy step, a scheduled task, a redirected # stdin) must be REFUSED instead -- it would otherwise open a browser nobody sees and wait five minutes. try { if (-not [Environment]::UserInteractive) { return $false } } catch { } try { if ([Console]::IsInputRedirected) { return $false } } catch { } return $true } function Get-PimMsExoAnchor { # X-AnchorMailbox for the Exchange admin API: the signed-in person's own UPN with a delegated token, the system # mailbox of the initial domain with an app-only one (what the EXO V3 module sends in each case). param([string]$Mode, [string]$Upn, [string]$InitialDomain) if ($Mode -eq 'browser' -and "$Upn".Trim()) { return "UPN:$("$Upn".Trim())" } return "UPN:SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}@$("$InitialDomain".Trim())" } # ---- browser sign-in -------------------------------------------------------------------------------------------------- function ConvertTo-PimMsB64Url { param([Parameter(Mandatory)][byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } # The claims of a JWT (claims read only, the resource validates the token; tid / upn / exp are read). $null when not a readable JWT. function ConvertFrom-PimMsJwtClaims { param([AllowEmptyString()][string]$Token) if (-not $Token) { return $null } $parts = $Token -split '\.' if ($parts.Count -lt 2) { return $null } $s = $parts[1].Replace('-', '+').Replace('_', '/') switch ($s.Length % 4) { 2 { $s += '==' } 3 { $s += '=' } } try { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) | ConvertFrom-Json } catch { $null } } function New-PimMsPkcePair { $bytes = New-Object byte[] 32 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) $verifier = ConvertTo-PimMsB64Url $bytes $challenge = ConvertTo-PimMsB64Url ([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::ASCII.GetBytes($verifier))) [pscustomobject]@{ Verifier = $verifier; Challenge = $challenge } } # The delegated scope string: bare Graph names are qualified, OIDC basics appended once. function Get-PimMsScopeString { param([Parameter(Mandatory)][string[]]$Scopes) $q = @($Scopes | Where-Object { $_ } | ForEach-Object { if ($_ -match '^(https?://|openid$|profile$|offline_access$)') { $_ } else { "https://graph.microsoft.com/$_" } }) (@($q) + @('openid', 'profile', 'offline_access') | Select-Object -Unique) -join ' ' } function New-PimMsAuthorizeUrl { param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$RedirectUri, [Parameter(Mandatory)][string]$Scope, [Parameter(Mandatory)][string]$State, [Parameter(Mandatory)][string]$CodeChallenge, [string]$LoginHint ) $u = "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/authorize" + "?client_id=$ClientId&response_type=code&response_mode=query" + "&redirect_uri=$([uri]::EscapeDataString($RedirectUri))" + "&scope=$([uri]::EscapeDataString($Scope))&state=$State" + "&code_challenge=$CodeChallenge&code_challenge_method=S256" if ($LoginHint) { $u += "&login_hint=$([uri]::EscapeDataString($LoginHint))" } else { $u += '&prompt=select_account' } $u } function Get-PimMsEdgePath { @( $(if (${env:ProgramFiles(x86)}) { Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe' }), $(if ($env:ProgramFiles) { Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe' }), $(if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe' }) ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 } function Get-PimMsSignInFailureHint { # PURE: one sentence for a sign-in error the person can act on ('' when there is nothing specific to say). param([string]$ErrorText) $e = "$ErrorText" if ($e -match 'AADSTS50011|redirect') { return 'This tenant refuses the local sign-in answer address for this Microsoft client. Run the script with the Invardia Support app instead: -AdminAppId -AdminSecret .' } if ($e -match 'AADSTS65001|consent') { return 'The sign-in needs an administrator''s consent: sign in as a Global Administrator once, or run with the Invardia Support app (-AdminAppId + -AdminSecret).' } if ($e -match 'AADSTS50105|AADSTS50020|not assigned|does not exist in tenant') { return 'Sign in with an account of THIS tenant (an Exchange Administrator or Global Administrator).' } return '' } # Interactive sign-in: auth code + PKCE on a loopback TcpListener (works without elevation), Edge launched explicitly, # the default browser when Edge is missing. Returns the token response (access_token, refresh_token, expires_in). function Get-PimMsInteractiveToken { param( [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string[]]$Scopes, [string]$Tenant = 'organizations', [bool]$UseEdge = $true, [string]$LoginHint, [string]$What = 'sign-in' ) $pkce = New-PimMsPkcePair $state = [guid]::NewGuid().ToString('N') $scope = Get-PimMsScopeString -Scopes $Scopes $tcp = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) $tcp.Start(); $tcps = @($tcp); try { if ([System.Net.Sockets.Socket]::OSSupportsIPv6) { $tcp6 = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::IPv6Loopback, ([System.Net.IPEndPoint]$tcp.LocalEndpoint).Port); $tcp6.Start(); $tcps += $tcp6 } } catch { } # a customer 2026-10-21: "localhost" may be [::1] -- BOTH loopbacks, same port $redirect = "http://localhost:$(([System.Net.IPEndPoint]$tcp.LocalEndpoint).Port)/" $authUrl = New-PimMsAuthorizeUrl -Tenant $Tenant -ClientId $ClientId -RedirectUri $redirect -Scope $scope -State $state -CodeChallenge $pkce.Challenge -LoginHint $LoginHint $query = $null try { $edge = if ($UseEdge) { Get-PimMsEdgePath } else { $null } Write-Host "Signing in for $What -- the browser answers to $redirect ..." -ForegroundColor Yellow if ($edge) { Start-Process -FilePath $edge -ArgumentList @('--new-window', $authUrl) -WhatIf:$false } else { if ($UseEdge) { Write-Host 'Microsoft Edge not found -- using the default browser.' -ForegroundColor DarkYellow } Start-Process $authUrl -WhatIf:$false } Write-Host "If no browser opened, open this address yourself:`n $authUrl" -ForegroundColor DarkGray # SIGNIN-PAGE (owner 2026-10-31): only the answer (code / error) ends the wait; every request gets a page; the page # keeps answering 10 s after it (PIM-LoopbackAnswer.ps1; tools/setup/_PimLoopbackAnswer.ps1 is its identical copy for the tools). if (-not (Get-Command Wait-PimLoopbackAnswer -ErrorAction SilentlyContinue)) { # ===== BEGIN inlined helper: _PimLoopbackAnswer.ps1 ===== # PIM-LoopbackAnswer.ps1 -- the ONE wait for a browser sign-in's answer on the localhost loopback (auth code + PKCE). # Used by every PIM browser sign-in: the REST library's own (signed-in setup scripts), the mail setup (Initialize-PimMailSender, # Set-PimSmtpRelayPassword, Set-PimSqlTier), the engine-permissions grant and the Activator deploy scripts. The tools load the # identical copy in tools\setup (they may not load engine files). Build-PimSupportScripts inlines it into each published script. # # Owner 2026-10-30/31 ("people are reporting this error" -- "This site can't be reached"; "yes fix the sign-in page in all # scripts"), the same fix as Invardia's Wait-AuthCode: the old loops took the FIRST request -- often the browser's preload # of the address or its icon request -- and a request that never sent a line held the loop, and once the answer came the # listener stopped at once, so the browser's real load or a reload got "This site can't be reached". Now: # 1. only a request whose query carries code= or error= is the answer; the loop goes on until one comes; # 2. EVERY request gets a page (waiting / signed in / failed), never a closed connection; # 3. after the answer the listener keeps answering for -GraceSeconds (10), so a second load shows "Signed in" too; # 4. it throws only when no answer came within -Seconds. # A connection that sends nothing (a speculative preconnect) is dropped after -ReadTimeoutMs and never blocks the loop. function Test-PimLoopbackAnswerQuery { # PURE. Is this request line the sign-in's answer? Returns the query string ('' when not). param([AllowEmptyString()][AllowNull()][string]$RequestLine) $m = [regex]::Match("$RequestLine", '^GET /[^?\s]*\?(?\S+) HTTP') if (-not $m.Success) { return '' } $q = $m.Groups['q'].Value if ($q -match '(^|&)(code|error)=') { return $q } return '' } function Get-PimLoopbackPage { # PURE. The page for one request: before the answer 'waiting', after it 'done' (or 'failed' when the answer is an error). param([ValidateSet('waiting', 'done', 'failed')][string]$Kind, [string]$ReturnTo = 'PowerShell', [string]$ErrorText = '') $body = switch ($Kind) { 'waiting' { '

Waiting for the sign-in...

Finish the sign-in in the other tab.' } 'done' { "

Sign-in complete.

You can close this tab and return to $([System.Net.WebUtility]::HtmlEncode($ReturnTo))." } 'failed' { "

Sign-in failed

$([System.Net.WebUtility]::HtmlEncode($ErrorText))
Return to $([System.Net.WebUtility]::HtmlEncode($ReturnTo))." } } "$body" } function Wait-PimLoopbackAnswer { <# Waits on the loopback listener(s) (TcpListener, started by the caller; both loopbacks may share one port) for the sign-in's answer and returns its raw query string. Throws only when no answer came within -Seconds. The caller still checks state / error / code and stops the listeners (this function does not stop them). #> param([Parameter(Mandatory)][object[]]$Listeners, [int]$Seconds = 300, [int]$GraceSeconds = 10, [int]$ReadTimeoutMs = 2000, [string]$ReturnTo = 'PowerShell', [string]$TimeoutMessage = '') $deadline = [DateTime]::UtcNow.AddSeconds($Seconds); $graceEnd = $null $answer = $null; $kind = 'waiting'; $errText = '' while ($true) { $now = [DateTime]::UtcNow if ($answer) { if ($now -ge $graceEnd) { break } } elseif ($now -ge $deadline) { break } $ready = @($Listeners | Where-Object { $_.Pending() })[0] if (-not $ready) { Start-Sleep -Milliseconds 100; continue } $client = $null try { $client = $ready.AcceptTcpClient() $client.ReceiveTimeout = $ReadTimeoutMs $stream = $client.GetStream() $reader = New-Object System.IO.StreamReader($stream) $line = $null try { $line = $reader.ReadLine() } catch { $line = $null } # a connection that sends nothing: dropped, never waited on if ($null -eq $line) { continue } if (-not $answer) { $q = Test-PimLoopbackAnswerQuery -RequestLine $line if ($q) { $answer = $q; $graceEnd = [DateTime]::UtcNow.AddSeconds($GraceSeconds) if ($q -match '(^|&)error=') { $kind = 'failed' $d = [regex]::Match($q, '(^|&)error_description=(?[^&]*)') $errText = if ($d.Success) { [uri]::UnescapeDataString(($d.Groups['d'].Value -replace '\+', ' ')) } else { '' } } else { $kind = 'done' } } } $html = Get-PimLoopbackPage -Kind $kind -ReturnTo $ReturnTo -ErrorText $errText $bytes = [Text.Encoding]::UTF8.GetBytes($html) $head = [Text.Encoding]::ASCII.GetBytes("HTTP/1.1 200 OK`r`nContent-Type: text/html; charset=utf-8`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n") $stream.Write($head, 0, $head.Length); $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() } catch { } # a browser that closed the connection itself: nothing to answer finally { if ($client) { try { $client.Close() } catch { } } } } if (-not $answer) { throw $(if ("$TimeoutMessage".Trim()) { $TimeoutMessage } else { "Timed out ($([int]($Seconds / 60)) minutes) waiting for the browser to finish the sign-in." }) } return $answer } # ===== END inlined helper: _PimLoopbackAnswer.ps1 ===== } $query = Wait-PimLoopbackAnswer -Listeners $tcps -Seconds 300 -TimeoutMessage 'Timed out (5 minutes) waiting for the browser to finish the sign-in.' } finally { foreach ($tcpL in $tcps) { try { $tcpL.Stop() } catch { } } } $kv = @{} foreach ($pair in ($query -split '&')) { $k, $v = $pair -split '=', 2 $kv[$k] = if ($null -ne $v) { [uri]::UnescapeDataString(($v -replace '\+', ' ')) } else { '' } } if ($kv['error']) { $hint = Get-PimMsSignInFailureHint -ErrorText "$($kv['error']) $($kv['error_description'])" throw "Sign-in failed: $($kv['error']) -- $($kv['error_description'])$(if ($hint) { " $hint" })" } if ($kv['state'] -ne $state) { throw 'The browser answer did not come from this sign-in attempt. Close ALL browser windows and run the script again.' } if (-not $kv['code']) { throw 'The browser answer carried no authorization code.' } Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ client_id = $ClientId grant_type = 'authorization_code' code = $kv['code'] redirect_uri = $redirect code_verifier = $pkce.Verifier scope = $scope } } # A token for another resource (or a fresh one) from a refresh token of the same public client -- no second browser tab. function Get-PimMsRefreshedToken { param([Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$RefreshToken, [Parameter(Mandatory)][string[]]$Scopes, [string]$Tenant = 'organizations') Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ client_id = $ClientId; grant_type = 'refresh_token'; refresh_token = $RefreshToken; scope = (Get-PimMsScopeString -Scopes $Scopes) } } # ---- the per-API token cache ------------------------------------------------------------------------------------------- function Get-PimMsBrowserApi { <# PURE: which public client + scopes serve a resource, and which sign-in it shares. Returns @{ api; clientId; scopes; family } -- family is the refresh-token group: 'azure' covers ARM, SQL and Key Vault (one sign-in), 'graph' and 'exchange' have their own. #> param([Parameter(Mandatory)][string]$Resource) $r = "$Resource".Trim().TrimEnd('/').ToLowerInvariant() switch -regex ($r) { '^(graph|https://graph\.microsoft\.com)$' { return @{ api = 'graph'; clientId = (Get-PimMsGraphCliClientId); scopes = @(Get-PimMsGraphScopes); family = 'graph' } } '^(exchange|https://outlook\.office365\.com)$' { return @{ api = 'exchange'; clientId = (Get-PimMsExchangeClientId); scopes = @('https://outlook.office365.com/.default'); family = 'exchange' } } '^(arm|https://management\.azure\.com)$' { return @{ api = 'arm'; clientId = (Get-PimMsAzurePowerShellClientId); scopes = @('https://management.azure.com/.default'); family = 'azure' } } '^(sql|https://database\.windows\.net)$' { return @{ api = 'sql'; clientId = (Get-PimMsAzurePowerShellClientId); scopes = @('https://database.windows.net/.default'); family = 'azure' } } '^(keyvault|https://vault\.azure\.net)$' { return @{ api = 'keyvault'; clientId = (Get-PimMsAzurePowerShellClientId); scopes = @('https://vault.azure.net/.default'); family = 'azure' } } } throw "no browser sign-in is defined for '$Resource'" } function Get-PimMsBrowserToken { <# An access token for -Resource as the person who signed in, cached per API for the run. The first call of a family opens the browser (the next ones carry the first sign-in's account as the login hint, so the browser does not ask again); a family's other APIs and an expiring token are served from its refresh token. Test seam: $script:PimMsAcquire = { param($ClientId, $Scopes, $Tenant, $LoginHint, $RefreshToken) }. #> param([Parameter(Mandatory)][string]$Resource, [Parameter(Mandatory)][string]$TenantId) if (-not $script:PimMsTokens) { $script:PimMsTokens = @{} } if (-not $script:PimMsRefresh) { $script:PimMsRefresh = @{} } $spec = Get-PimMsBrowserApi -Resource $Resource $now = (Get-Date).ToUniversalTime() $hit = $script:PimMsTokens[$spec.api] if ($hit -and $hit.expires -gt $now.AddMinutes(5)) { return $hit.token } $acquire = $script:PimMsAcquire $rt = $script:PimMsRefresh[$spec.family] $resp = $null if ($rt) { try { $resp = if ($acquire) { & $acquire $spec.clientId $spec.scopes $TenantId $script:PimMsLoginHint $rt } else { Get-PimMsRefreshedToken -ClientId $spec.clientId -RefreshToken $rt -Scopes $spec.scopes -Tenant $TenantId } } catch { $resp = $null } } if (-not $resp) { if (-not $acquire -and -not (Test-PimMsInteractiveHost)) { throw 'a browser sign-in needs a person at this console, and this session is not interactive -- run the command in a PowerShell window, or pass -AdminAppId + -AdminSecret (the Invardia Support app).' } $what = switch ($spec.family) { 'graph' { 'Microsoft Graph' } 'exchange' { 'Exchange Online' } default { 'Azure' } } $resp = if ($acquire) { & $acquire $spec.clientId $spec.scopes $TenantId $script:PimMsLoginHint $null } else { Get-PimMsInteractiveToken -ClientId $spec.clientId -Scopes $spec.scopes -Tenant $TenantId -LoginHint $script:PimMsLoginHint -What $what } } $tok = "$($resp.access_token)" if (-not $tok) { throw "the sign-in returned no access token for $($spec.api)" } $claims = ConvertFrom-PimMsJwtClaims -Token $tok if ($claims -and "$($claims.tid)".Trim() -and "$($claims.tid)".Trim() -ine "$TenantId".Trim()) { throw "you signed in to tenant $($claims.tid), not $TenantId -- sign in with an account of the tenant this environment runs in." } if ($claims -and -not $script:PimMsLoginHint) { $u = "$(if ($claims.upn) { $claims.upn } elseif ($claims.preferred_username) { $claims.preferred_username } else { $claims.unique_name })".Trim() if ($u) { $script:PimMsLoginHint = $u } } if ("$($resp.refresh_token)".Trim()) { $script:PimMsRefresh[$spec.family] = "$($resp.refresh_token)" } $secs = 3000; try { if ([int]$resp.expires_in -gt 0) { $secs = [int]$resp.expires_in } } catch { } $script:PimMsTokens[$spec.api] = @{ token = $tok; expires = $now.AddSeconds($secs) } return $tok } function Get-PimMsSignedInUpn { # The person who signed in (the first token's upn), '' before any sign-in. "$($script:PimMsLoginHint)".Trim() } function Get-PimMsAppToken { # An app-only token for -Resource as the Invardia Support app (client credentials, its secret). No module. param([Parameter(Mandatory)][string]$TenantId, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$ClientSecret, [Parameter(Mandatory)][string]$Resource) $spec = Get-PimMsBrowserApi -Resource $Resource $scope = (@($spec.scopes) | Where-Object { $_ -match '^https://' } | Select-Object -First 1) if (-not $scope) { $scope = 'https://graph.microsoft.com/.default' } $scope = ($scope -replace '/[^/]+$', '/.default') (Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ client_id = $ClientId; client_secret = $ClientSecret; grant_type = 'client_credentials'; scope = $scope }).access_token } # ---- the SMTP relay password in Key Vault (PURE pieces; Set-PimSmtpRelayPassword.ps1 does the calls) ------------------- function Get-PimKvRoleId { # Built-in Azure roles by name (the ids are the same in every tenant). param([Parameter(Mandatory)][ValidateSet('SecretsUser', 'SecretsOfficer')][string]$Role) switch ($Role) { 'SecretsUser' { '4633458b-17de-408a-b874-0445c86b69e6' } 'SecretsOfficer' { 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7' } } } function Select-PimKvVault { # PURE: the ONE vault called -Name in a subscription's vault list (ARM value[]), or @{ vault = $null; reason }. param([object[]]$Vaults = @(), [Parameter(Mandatory)][string]$Name) $hits = @(@($Vaults) | Where-Object { $_ -and "$($_.name)" -ieq "$Name".Trim() }) if ($hits.Count -eq 1) { return @{ vault = $hits[0]; reason = '' } } if ($hits.Count -gt 1) { return @{ vault = $null; reason = "more than one Key Vault is called $Name -- refusing to pick one" } } return @{ vault = $null; reason = "no Key Vault called $Name in this subscription" } } function Get-PimKvSecretScope { # PURE: the Azure RBAC scope of ONE secret -- the read right is granted on the secret, never on the whole vault. param([Parameter(Mandatory)][string]$VaultId, [Parameter(Mandatory)][string]$SecretName) "$("$VaultId".TrimEnd('/'))/secrets/$SecretName" } function New-PimKvRoleAssignmentRequest { # PURE: the ARM PUT of one role assignment (deterministic name per scope + principal + role, so a re-run is the same # request and ARM answers "already exists" instead of creating a second one). param([Parameter(Mandatory)][string]$Scope, [Parameter(Mandatory)][string]$SubscriptionId, [Parameter(Mandatory)][string]$RoleId, [Parameter(Mandatory)][string]$PrincipalId, [ValidateSet('ServicePrincipal', 'User', 'Group')][string]$PrincipalType = 'ServicePrincipal') $md5 = [Security.Cryptography.MD5]::Create() $bytes = $md5.ComputeHash([Text.Encoding]::UTF8.GetBytes(("{0}|{1}|{2}" -f $Scope.ToLowerInvariant(), $PrincipalId.ToLowerInvariant(), $RoleId.ToLowerInvariant()))) $name = ([guid]::new($bytes)).ToString() @{ path = "$Scope/providers/Microsoft.Authorization/roleAssignments/$name`?api-version=2022-04-01" body = @{ properties = @{ roleDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/$RoleId"; principalId = $PrincipalId; principalType = $PrincipalType } } } } # ===== END inlined helper: _PimMailSetup.ps1 ===== # ===== BEGIN inlined helper: _PimScriptDoc.ps1 ===== <# .SYNOPSIS Framework 12.7 SCRIPT-DOC-1 + 12.10 item 11 -- the ONE place that knows where a PIM script's documentation page, download and checksum list live, the command form every surface shows (line 1 download, line 2+ run -- owner 2026-10-11), and the run frame every script a person runs uses (the first line "