#Requires -Version 7.0 # PIM Manager 3.0.70 -- Deploy-PimActivatorBackend.ps1, standalone (built by tools/setup/Build-PimSupportScripts.ps1; helpers inlined: _pimactivatorauth.ps1, ..\setup\_pimloopbackanswer.ps1, _pimactivatorbackend.ps1, ..\setup\_pimscriptdoc.ps1) <# .SYNOPSIS Creates or updates the PIM Activator app registration in your tenant (the sign-in app of the PIM Activator browser extension): its SPA redirect URIs, its delegated Microsoft Graph and Azure permissions, and tenant-wide admin consent for them. Every user of the tenant can sign in to the app ("Assignment required" is off); what a person can activate is decided by their PIM eligibility, not by this app. .DESCRIPTION Documentation (purpose, who may run it, every change and permission, -WhatIf, undo): https://invardia.com/docs/pim/scripts/Deploy-PimActivatorBackend/ -WhatIf signs in, reads the tenant and prints every change it would make; it changes nothing. Who can sign in: every user of your tenant. The app is only how the extension signs in -- it gives nobody any access. What a person sees and can activate is decided by their PIM eligibility. On every run the script makes sure "Assignment required" is OFF on the enterprise application (a tenant where an earlier version of this script switched it on comes back to that), and says so. Users and groups already assigned to the enterprise application are left as they are: with assignment not required they change nothing. The PIM Activator extension uses chrome.identity.launchWebAuthFlow + PKCE (vanilla JS, no MSAL.js) to: - Read the signed-in user's eligible PIM-for-Groups assignments - POST assignmentScheduleRequests to bulk-activate selected groups The redirect URIs are registered as SPA (Single-Page Application) URIs -- both https://.chromiumapp.org/ (what launchWebAuthFlow intercepts) and chrome-extension:/// (the Origin the popup's token-endpoint fetch sends). Entra validates that Origin against SPA-registered URIs; a Public Client / native registration fails redemption with AADSTS9002326 ("Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type"). Any stale Public Client URI a previous install left behind is cleared. By default ONLY the released extension id is registered (-Channel Released). The TEST build's id is added only when you ask for it (-Channel Both / Test) -- never on a customer tenant: the app carries tenant-wide consent for RoleManagement.ReadWrite.Directory, so every registered redirect is a build that can hold those tokens. Required delegated permissions (resolved by displayName -> id at runtime): Microsoft Graph (00000003-0000-0000-c000-000000000000): - PrivilegedAccess.ReadWrite.AzureADGroup (POST/DELETE activation -- ReadWrite REQUIRED, Read-only breaks Activate) - Group.Read.All - User.Read - RoleManagement.Read.Directory - RoleManagement.ReadWrite.Directory (activate direct Entra role assignments) - AdministrativeUnit.Read.All - Application.Read.All (first-run onboarding wizard -- discovers the per-tenant app reg by displayName) Azure Service Management (797f4846-ba00-4fd7-ba43-dac1f8f63013): - user_impersonation (mint ARM token for Azure RBAC eligibility + activation) - User.Read - RoleManagement.Read.Directory (powers the "My Access" tab -- lists Entra role assignments attached to the user's active PIM-for-Groups memberships. Admin-consentable. Without it, the My Access tab still renders memberships but each row shows a 403 for the role lookup.) - AdministrativeUnit.Read.All (resolves AU displayNames in the My Access tab. RoleManagement.Read.Directory exposes the AU id on each role assignment but reading the AU object requires this scope; without it, the popup renders "scoped to N Administrative Units" without names.) Sign-in: no PowerShell modules are needed. By default the script opens Microsoft Edge (the default browser when Edge is not installed) for an interactive sign-in -- auth code + PKCE on a localhost loopback, using the first-party "Microsoft Graph Command Line Tools" client, so no extra app registration. Alternatives: -AccessToken (a Microsoft Graph token you already have) or app-only -AppId + -CertificateThumbprint + -TenantId. Caller (you) must have: - Graph scopes: Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All - Entra roles, ACTIVE in the session (PIM-eligible does NOT count until activated -- activate in PIM FIRST, then run this script; a sign-in made before the activation does not carry the role, and the script signs you in once more when it sees that): * Application Administrator OR Cloud Application Administrator OR Global Administrator -- app registration + service principals * Privileged Role Administrator OR Global Administrator -- only when -GrantConsent is used (tenant-wide admin consent incl. the protected RoleManagement.ReadWrite.Directory scope) The script pre-flights these and stops with actionable guidance when a required role is missing or not yet activated. .PARAMETER ExtensionId The Edge extension id assigned when you load the unpacked extension at edge://extensions (Developer Mode -> Load unpacked). Used to build the SPA redirect URI: https://.chromiumapp.org/ .PARAMETER AssignToGroupId No longer used. Earlier versions assigned this group to the app and set "Assignment required". The parameter is still accepted, so a command copied from an earlier PIM Manager page keeps working: the script says in one line that it is ignored, assigns nobody and continues. .PARAMETER DisplayName Display name of the app registration. Default: "PIM Activator". .PARAMETER GrantConsent Also grant tenant-wide admin consent for the delegated permissions so individual users don't get the consent prompt on first sign-in. Requires the caller to be Privileged Role Administrator or higher. .PARAMETER TenantId Optional for the browser sign-in (omitted = the home tenant of the account you sign in with). Required for app-only sign-in. .PARAMETER AccessToken A Microsoft Graph access token you already have (automation), with the delegated scopes above (or the equivalent application permissions). Skips the browser sign-in. Not renewed -- valid ~1 hour from when it was minted. .PARAMETER AppId App-only sign-in: the client id of an app registration that holds Application.ReadWrite.All (and DelegatedPermissionGrant.ReadWrite.All for -GrantConsent) as APPLICATION permissions. Use with -CertificateThumbprint and -TenantId. .PARAMETER CertificateThumbprint App-only sign-in: thumbprint of that app's certificate, with its private key, in Cert:\CurrentUser\My or Cert:\LocalMachine\My. .EXAMPLE # The command the PIM Manager page shows: this tenant, PROD: .\Deploy-PimActivatorBackend.ps1 -TenantId -Channel Released -DisplayName 'PIM Activator' .EXAMPLE # Zero-arg -- opens Edge for the sign-in, with the right scopes: .\Deploy-PimActivatorBackend.ps1 .EXAMPLE # A specific tenant: .\Deploy-PimActivatorBackend.ps1 -TenantId -ExtensionId 'abcd...wxyz' -GrantConsent .EXAMPLE # Automation with a token you already have: .\Deploy-PimActivatorBackend.ps1 -TenantId -AccessToken $token .EXAMPLE # Headless, app-only with a certificate: .\Deploy-PimActivatorBackend.ps1 -TenantId -AppId -CertificateThumbprint .EXAMPLE # Preview: signs in, reads, prints every change it would make -- changes nothing: .\Deploy-PimActivatorBackend.ps1 -TenantId -WhatIf .NOTES Requirements: PowerShell: 7.0 or later (pwsh). Windows PowerShell 5.1 is not supported. Role: Application Administrator and Privileged Role Administrator (Microsoft Entra), both active. Hosts: login.microsoftonline.com, graph.microsoft.com PERMISSIONS AND CHANGES (from Deploy-PimActivatorBackend.ps1 manifest, PIM Manager 3.0.70; full page: https://invardia.com/docs/pim/scripts/Deploy-PimActivatorBackend/) The person running it needs: - Application Administrator [entraRole, tenant (active, not only eligible)] -- creates / updates the app registration and its service principal, including its 'Assignment required' setting. Cloud Application Administrator or Global Administrator also work. - Privileged Role Administrator [entraRole, tenant (active, not only eligible)] -- only with -GrantConsent (the default): tenant-wide admin consent that includes the protected RoleManagement.ReadWrite.Directory permission. Global Administrator also works; -GrantConsent:$false skips it. - Delegated sign-in: Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All [other, Microsoft Graph, for the first-party app Microsoft Graph Command Line Tools] -- the browser sign-in (auth code + PKCE, no extra app) asks for exactly these delegated permissions; they act only within your own roles above. Automation alternatives: -AccessToken with the same scopes, or -AppId + -CertificateThumbprint of an app holding the equivalent application permissions. What it creates, changes or grants: - create service principals of the Microsoft first-party APIs Microsoft Graph and Azure Service Management -- only when the tenant does not have them yet (Microsoft Entra ID) -- the delegated permission ids are read from these service principals; a fresh tenant may not have instantiated them Undo: none needed -- Microsoft first-party service principals; leave them - create app registration 'PIM Activator' (-DisplayName), single tenant (AzureADMyOrg); updated in place when it exists (Microsoft Entra ID) -- the extension signs users in with it Undo: delete the app registration (Entra admin center > App registrations > PIM Activator > Delete) - update SPA redirect URIs https://.chromiumapp.org/ and chrome-extension:/// (merged with the URIs already there; public-client URIs cleared). -Channel Both / Test also adds the TEST build's id (internal tenants only) (app registration 'PIM Activator') -- launchWebAuthFlow returns to the chromiumapp.org URI; Entra validates the extension origin on token redemption Undo: remove the URIs under Authentication, or delete the app registration - update required permissions (delegated): Microsoft Graph PrivilegedAccess.ReadWrite.AzureADGroup, Group.Read.All, User.Read, RoleManagement.Read.Directory, RoleManagement.ReadWrite.Directory, AdministrativeUnit.Read.All, Application.Read.All; Azure Service Management user_impersonation (app registration 'PIM Activator') -- what the extension needs to list and activate PIM eligibilities (groups, Entra roles, Azure roles) and to find this app in its setup wizard. PrivilegedAccess.Read.AzureADGroup is not enough: activation needs ReadWrite. Undo: remove them under API permissions, or delete the app registration - create service principal (enterprise application) of 'PIM Activator' (Microsoft Entra ID) -- the tenant's instance of the app: the consent and the 'Assignment required' setting live on it Undo: deleted with the app registration - update 'Assignment required' = No (appRoleAssignmentRequired = false) -- only written when it is Yes today, for example because an earlier version of this script set it (enterprise application 'PIM Activator') -- every user of the tenant can sign in to the PIM Activator. The app is only how the extension signs in: what a person can activate is decided by their PIM eligibility. Nobody is assigned by this script, and users and groups already assigned to the enterprise application are left as they are -- with assignment not required they change nothing. Undo: Enterprise applications > PIM Activator > Properties > Assignment required = Yes (then assign who may sign in under Users and groups; the next run of this script switches it off again) - grant tenant-wide admin consent (oauth2PermissionGrant, consentType AllPrincipals) from 'PIM Activator' to Microsoft Graph (Microsoft Graph): delegated: PrivilegedAccess.ReadWrite.AzureADGroup Group.Read.All User.Read RoleManagement.Read.Directory RoleManagement.ReadWrite.Directory AdministrativeUnit.Read.All Application.Read.All openid profile offline_access @ all users of the tenant; a delegated permission acts only within the signed-in user's own rights -- users are not asked to consent one by one (with -GrantConsent, the default) Undo: Enterprise applications > PIM Activator > Permissions > revoke, or delete the oauth2PermissionGrant - grant tenant-wide admin consent (oauth2PermissionGrant, AllPrincipals) from 'PIM Activator' to Azure Service Management (Azure Service Management): delegated: user_impersonation @ all users of the tenant; acts only within the user's own Azure rights -- the extension lists and activates the user's Azure role eligibilities Undo: Enterprise applications > PIM Activator > Permissions > revoke -WhatIf: Signs in, reads the tenant, and prints a 'What if:' line for every change: the app registration it would create or update (with its redirect URIs and permissions), the service principal it would create, 'Assignment required' = No when it is Yes today, and the two admin-consent grants. Nothing is written. Undo: Delete the app registration 'PIM Activator' in the Entra admin center (App registrations > PIM Activator > Delete): that removes its service principal and its consent grants with it. The PIM Activator extension can then no longer sign in. Re-runnable: if an app with the same DisplayName already exists in the tenant, the script updates its redirect URI + required permissions in place rather than creating a duplicate. A transcript of the run is written to \pim-manager-logs\ (its path is printed). NO MODULES (owner 2026-10-07: "neither pim, si or invardia must have dependencies"): plain Microsoft Graph REST (Invoke-RestMethod + .NET) in every mode -- browser sign-in, app-only certificate, or a token you pass. PowerShell 7 (owner 2026-11-01, HOST-2). Published standalone as https://invardia.com/support/pim/Deploy-PimActivatorBackend.ps1 (one file: tools/setup/Build-PimSupportScripts.ps1 inlines the helpers). .LINK https://invardia.com/docs/pim/scripts/Deploy-PimActivatorBackend/ #> [CmdletBinding(SupportsShouldProcess)] param( # Extension id is derived from the manifest.json "key" field, which is # identical across every install of this distribution -- so default it # rather than make every operator look it up. Override only if you fork # the extension under a different key. [ValidatePattern('^[a-p]{32}$')] # Chromium extension id format [string]$ExtensionId = 'eheocihmlppcophaeakmdenhgcookkab', # Which extension ids get redirect URIs registered (always merged, never replacing): # 'Released' (DEFAULT) registers ONLY the released id -- the customer-tenant deploy. # (SEC-37: the default used to be 'Both', which put the TEST build's redirect on # every customer app that holds tenant-wide RoleManagement.ReadWrite.Directory.) # 'Both' registers the released id AND the TEST id, so prod + test builds both sign # in -- for the internal/dev tenant only; ask for it explicitly. # 'Test' is kept as an alias of 'Both' for back-compat. [ValidateSet('Both','Released','Test')] [string]$Channel = 'Released', [string]$DisplayName = 'PIM Activator', [string]$TenantId, # Default ON: a fresh app reg without admin consent makes every user hit # the per-user consent dialog on first sign-in, which most operators don't # want. Pass -GrantConsent:$false to skip the tenant-wide consent step # (rare -- e.g. when the caller doesn't hold Privileged Role Administrator # and a delegated approver will consent later via the Enterprise apps blade). [switch]$GrantConsent = $true, # Default ON: open the browser sign-in in Microsoft Edge explicitly # instead of the system default browser -- on servers that is often legacy # Internet Explorer, which mangles the auth redirect. Falls back to the # default browser when Edge is not installed. Pass -UseEdge:$false to use # the default browser always. [switch]$UseEdge = $true, # App-only (certificate) sign-in for headless deployment. Supply both with # -TenantId; the app must hold Application.ReadWrite.All (app-reg CRUD) and, # for -GrantConsent, DelegatedPermissionGrant.ReadWrite.All. When used, the # browser sign-in + delegated role pre-flight are skipped. [string]$AppId, [string]$CertificateThumbprint, # A Microsoft Graph access token you already have (automation). Skips the # browser sign-in; not renewed (valid ~1 hour from when it was minted). [string]$AccessToken, # 100.90 (owner 2026-10-11: "we dont use it to assign or login ... strip out the user # assignement part"): NOT USED any more. The 97.2 pages (2026-10-08 to 2.4.595) filled # "-AssignToGroupId " into the command they showed, so a command someone # already copied still carries it: it is accepted, said to be ignored in one line, # and nothing is assigned. Hidden from tab completion (DontShow). [Parameter(DontShow)] [string]$AssignToGroupId ) $ErrorActionPreference = 'Stop' $script:PimActivatorAppOnly = [bool]($AppId -and $CertificateThumbprint -and -not $AccessToken) # Windows PowerShell 5.1 may default to TLS 1.0/1.1; Entra + Graph need 1.2. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } # Shared sign-in (browser PKCE / app-only certificate / provided token) + the version banner. # ===== BEGIN inlined helper: _PimActivatorAuth.ps1 ===== # _PimActivatorAuth.ps1 -- shared Microsoft Graph sign-in for the pim-activator deploy scripts. Dot-source from a # sibling script; defines functions only (no side effects on load). # # NO MODULES (owner 2026-10-07: "neither pim, si or invardia must have dependencies"): plain REST + .NET, nothing from # the PowerShell Gallery. The token lives in a script variable ($script:PaGraphToken, see _PimActivatorBackend.ps1) and # every Graph call goes through Invoke-PaGraph. Three ways in: # * Interactive: browser sign-in, auth code + PKCE on a localhost loopback (the first-party "Microsoft Graph Command # Line Tools" public client, so no extra app registration). Edge is launched explicitly -- servers whose default # browser is legacy Internet Explorer mangle the redirect -- with the system default browser as the fallback when # Edge is not installed. # * App-only: -AppId + -CertificateThumbprint + -TenantId. The token is minted here: client_credentials with an RS256 # client assertion signed by the certificate (Cert:\CurrentUser\My or Cert:\LocalMachine\My). Windows PowerShell # 5.1 and PowerShell 7. # * A token you already have (-AccessToken), for automation. # Also used by Deploy-PimActivatorClient.ps1 for its version banner only. # VERSION lives at the PIM4EntraPS solution root, two levels up from tools/pim-activator/. A standalone (built) copy # has no VERSION file next to it: then the version is read from the header line the build writes into the file. function Get-PimActivatorSolutionVersion { $f = Join-Path $PSScriptRoot '..\..\VERSION' if (Test-Path -LiteralPath $f) { return 'v' + (Get-Content -LiteralPath $f -TotalCount 1).Trim() } foreach ($self in @($PSCommandPath, $MyInvocation.ScriptName) | Where-Object { $_ }) { try { $head = @(Get-Content -LiteralPath $self -TotalCount 5 -ErrorAction Stop) foreach ($l in $head) { if ($l -match '^# PIM Manager (\S+) --') { return 'v' + $Matches[1] + ' (standalone)' } } } catch { } } '(VERSION file not found)' } # Troubleshooting banner: script + solution version and the PowerShell runtime. -GraphModules / -AzModules are still # accepted for scripts that use those modules on an optional path (Deploy-PimActivatorClient.ps1); they are REPORTED # (installed version or "not installed"), never loaded. -GraphOptional is accepted for compatibility and changes # nothing any more -- nothing here can fail on a missing module. function Show-PimActivatorBanner { param( [Parameter(Mandatory)][string]$ScriptName, [string[]]$GraphModules, [string[]]$AzModules, [switch]$GraphOptional ) Write-Host "$ScriptName -- PIM Manager $(Get-PimActivatorSolutionVersion)" -ForegroundColor Cyan foreach ($m in @(@($GraphModules) + @($AzModules) | Where-Object { $_ })) { $mod = Get-Module -Name $m -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $mod) { $mod = Get-Module -ListAvailable -Name $m -ErrorAction SilentlyContinue | Sort-Object Version -Descending | Select-Object -First 1 } $v = if ($mod) { "v$($mod.Version)" } else { 'not installed' } Write-Host ("{0,-11}: {1}" -f $m, $v) -ForegroundColor Cyan } Write-Host "PowerShell : $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" -ForegroundColor Cyan } # The guidance shown whenever a host cannot complete the sign-in. function Get-PaBrokenAuthHelp { @" This host cannot complete the sign-in. Known causes + fixes, in order of likelihood: 1. The system default browser is legacy Internet Explorer, which mangles the auth redirect. This script launches Microsoft Edge explicitly (-UseEdge, the default); if you passed -UseEdge:`$false, drop it. To fix the host itself: Settings > Default apps > set Microsoft Edge as default for HTTP/HTTPS. 2. Stale pending sign-in tabs answering the listener with an old state: close ALL browser windows, retry once. 3. Something on this host blocks the localhost listener the browser answers to (a local firewall or proxy rule for 127.0.0.1): allow it, or run this script from another machine where sign-in works (it only talks to Microsoft Graph -- nothing tenant-side requires this host). 4. Bring a token: sign in somewhere that works, mint a Microsoft Graph access token with Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All, and pass it with -AccessToken (valid ~1 hour). "@ } # base64url (RFC 7515 section 2): no padding, URL-safe alphabet. function ConvertTo-PaB64Url { param([Parameter(Mandatory)][byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function ConvertFrom-PaB64Url { param([Parameter(Mandatory)][string]$Text) $s = $Text.Replace('-', '+').Replace('_', '/') switch ($s.Length % 4) { 2 { $s += '==' } 3 { $s += '=' } } [Convert]::FromBase64String($s) } # The claims of a JWT (claims read only -- Graph validates the token; tid/upn/wids/scp/roles/exp are read). $null when the # string is not a readable JWT (an opaque token, or garbage). function ConvertFrom-PaJwtClaims { param([AllowEmptyString()][string]$Token) if (-not $Token) { return $null } $parts = $Token -split '\.' if ($parts.Count -lt 2) { return $null } try { [Text.Encoding]::UTF8.GetString((ConvertFrom-PaB64Url $parts[1])) | ConvertFrom-Json } catch { $null } } # Seconds since the Unix epoch (5.1-safe: no DateTimeOffset.ToUnixTimeSeconds dependency on the caller). function Get-PaUnixTime { param([datetime]$Utc = [datetime]::UtcNow) if ($Utc.Kind -eq [DateTimeKind]::Unspecified) { $Utc = [datetime]::SpecifyKind($Utc, [DateTimeKind]::Utc) } [long][Math]::Floor(($Utc.ToUniversalTime() - (New-Object datetime(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc))).TotalSeconds) } # Store a Graph access token as THE session token: claims decoded, expiry noted, renewal recipe kept. -Refresh is a # plain hashtable (no scriptblock closures -- those cannot see this file's functions): @{ Kind = 'AppOnly'; TenantId; # ClientId; Thumbprint } or @{ Kind = 'RefreshToken'; RefreshToken; Tenant; Scopes }. None = cannot be renewed (a token # passed with -AccessToken). function Set-PaGraphSession { param( [Parameter(Mandatory)][string]$AccessToken, [ValidateSet('Interactive', 'AppOnly', 'ProvidedToken')][string]$AuthType = 'ProvidedToken', [int]$ExpiresInSeconds = 0, [hashtable]$Refresh ) $script:PaGraphToken = $AccessToken $script:PaTokenClaims = ConvertFrom-PaJwtClaims $AccessToken $script:PaAuthType = $AuthType $exp = $null if ($script:PaTokenClaims -and $script:PaTokenClaims.PSObject.Properties['exp']) { $exp = (New-Object datetime(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).AddSeconds([double]$script:PaTokenClaims.exp) } elseif ($ExpiresInSeconds -gt 0) { $exp = [datetime]::UtcNow.AddSeconds($ExpiresInSeconds) } $script:PaGraphTokenExpiresUtc = $exp $script:PaGraphTokenRefresh = $Refresh } # Renew the session token from its recipe (called by Get-PaGraphAccessToken when the token is about to expire). function Update-PaGraphSession { $r = $script:PaGraphTokenRefresh if (-not $r) { return } switch ($r.Kind) { 'AppOnly' { $tok = Get-PaAppOnlyToken -TenantId $r.TenantId -ClientId $r.ClientId -CertificateThumbprint $r.Thumbprint Set-PaGraphSession -AccessToken "$($tok.access_token)" -AuthType AppOnly -ExpiresInSeconds ([int]"0$($tok.expires_in)") -Refresh $r } 'RefreshToken' { $tok = Get-PaRefreshedToken -RefreshToken $r.RefreshToken -Tenant $r.Tenant -Scopes $r.Scopes if ($tok.refresh_token) { $r.RefreshToken = "$($tok.refresh_token)" } Set-PaGraphSession -AccessToken "$($tok.access_token)" -AuthType Interactive -ExpiresInSeconds ([int]"0$($tok.expires_in)") -Refresh $r } } } # PKCE verifier + S256 challenge (RFC 7636). function New-PaPkcePair { $bytes = New-Object byte[] 32 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) $verifier = ConvertTo-PaB64Url $bytes $challenge = ConvertTo-PaB64Url ([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::ASCII.GetBytes($verifier))) [pscustomobject]@{ Verifier = $verifier; Challenge = $challenge } } # The Graph delegated scope string: bare names are qualified with the Graph resource, OIDC basics appended once. function Get-PaDelegatedScopeString { param([Parameter(Mandatory)][string[]]$Scopes) $q = @($Scopes | Where-Object { $_ } | ForEach-Object { if ($_ -match '^(https?://|openid$|profile$|offline_access$|email$)') { $_ } else { "https://graph.microsoft.com/$_" } }) (@($q) + @('openid', 'profile', 'offline_access') | Select-Object -Unique) -join ' ' } # The authorize URL for the loopback auth-code + PKCE flow (pure, unit-tested). function New-PaAuthorizeUrl { 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 ) "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&prompt=select_account" } # msedge.exe, or $null. function Get-PaEdgePath { @( $(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 } # Microsoft Graph Command Line Tools (first-party public client; the same app the Graph PowerShell SDK signs in with). function Get-PaGraphCliClientId { '14d82eec-204b-4c2f-b7e8-296a70dab67e' } # Interactive browser sign-in: auth code + PKCE on a loopback TcpListener (no HttpListener URL-ACL, works non-elevated), # Edge launched explicitly (default browser when Edge is missing or -UseEdge:$false). Returns the token response. function Get-PaInteractiveToken { param( [Parameter(Mandatory)][string[]]$Scopes, [string]$Tenant = 'organizations', [bool]$UseEdge = $true ) $clientId = Get-PaGraphCliClientId $pkce = New-PaPkcePair $state = [guid]::NewGuid().ToString('N') $scope = Get-PaDelegatedScopeString -Scopes $Scopes # OS-assigned free port; first-party public clients accept any localhost port on the redirect URI. $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-PaAuthorizeUrl -Tenant $Tenant -ClientId $clientId -RedirectUri $redirect -Scope $scope -State $state -CodeChallenge $pkce.Challenge $query = $null try { $edge = if ($UseEdge) { Get-PaEdgePath } else { $null } if ($edge) { Write-Host "Launching Edge for sign-in (loopback listener on $redirect)..." -ForegroundColor Yellow Start-Process -FilePath $edge -ArgumentList @('--new-window', $authUrl) -WhatIf:$false # opening the sign-in page is not a change: -WhatIf still signs in } else { if ($UseEdge) { Write-Host 'Microsoft Edge not found -- using the default browser.' -ForegroundColor DarkYellow } Write-Host "Opening the default browser for sign-in (loopback listener on $redirect)..." -ForegroundColor Yellow Start-Process $authUrl -WhatIf:$false } Write-Host "If no browser opened, open this URL yourself:`n $authUrl" -ForegroundColor DarkGray # SIGNIN-PAGE (owner 2026-10-31, Invardia's Wait-AuthCode fix): only the answer (code / error) ends the wait, every # request gets a page, and 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: ..\setup\_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: ..\setup\_PimLoopbackAnswer.ps1 ===== } $query = Wait-PimLoopbackAnswer -Listeners $tcps -Seconds 300 -TimeoutMessage 'Timed out (5 min) waiting for the sign-in redirect from the browser.' } 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']) { throw "Sign-in failed: $($kv['error']) -- $($kv['error_description'])" } if ($kv['state'] -ne $state) { throw 'State mismatch on the loopback redirect -- the response did not come from this sign-in attempt. Close ALL browser windows and retry.' } if (-not $kv['code']) { throw 'Sign-in redirect 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 } } # Renew a delegated token with its refresh token (same public client, same scopes). function Get-PaRefreshedToken { param([Parameter(Mandatory)][string]$RefreshToken, [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string[]]$Scopes) Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ client_id = (Get-PaGraphCliClientId) grant_type = 'refresh_token' refresh_token = $RefreshToken scope = (Get-PaDelegatedScopeString -Scopes $Scopes) } } # The certificate (with its private key) by thumbprint: CurrentUser\My first, then LocalMachine\My. function Get-PaCertificate { param([Parameter(Mandatory)][string]$Thumbprint) $tp = ($Thumbprint -replace '[^0-9A-Fa-f]', '').ToUpperInvariant() foreach ($loc in 'CurrentUser', 'LocalMachine') { $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('My', $loc) try { $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) $hit = @($store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $tp, $false)) | Select-Object -First 1 } finally { $store.Close() } if ($hit) { if (-not $hit.HasPrivateKey) { throw "Certificate $tp found in Cert:\$loc\My but without its private key -- app-only sign-in needs the key." } return $hit } } throw "Certificate $tp not found in Cert:\CurrentUser\My or Cert:\LocalMachine\My." } # RS256 signature over $Data with the certificate's private key. Old CSP-stored keys (PROV_RSA_FULL) cannot hash SHA-256; # for those the same key container is reopened under the enhanced (AES) provider, the classic 5.1 workaround. function Invoke-PaRsaSha256Sign { param([Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [Parameter(Mandatory)][byte[]]$Data) $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if (-not $rsa) { throw "Certificate $($Certificate.Thumbprint) has no RSA private key." } try { return $rsa.SignData($Data, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) } catch { if ($rsa -isnot [System.Security.Cryptography.RSACryptoServiceProvider]) { throw } $info = $rsa.CspKeyContainerInfo $cp = New-Object System.Security.Cryptography.CspParameters(24, 'Microsoft Enhanced RSA and AES Cryptographic Provider', $info.KeyContainerName) $cp.KeyNumber = [int]$info.KeyNumber $cp.Flags = [System.Security.Cryptography.CspProviderFlags]::UseExistingKey if ($info.MachineKeyStore) { $cp.Flags = $cp.Flags -bor [System.Security.Cryptography.CspProviderFlags]::UseMachineKeyStore } $csp = New-Object System.Security.Cryptography.RSACryptoServiceProvider($cp) try { return $csp.SignData($Data, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) } finally { $csp.Dispose() } } } # The client assertion for client_credentials (RFC 7523): header {alg RS256, typ JWT, x5t = base64url(SHA-1 cert hash)}, # claims {aud = the v2 token endpoint, iss = sub = client id, jti, nbf, exp (10 min)}. Pure apart from the signature. function New-PaClientAssertion { param( [Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$TenantId, [datetime]$NowUtc = [datetime]::UtcNow, [int]$LifetimeSeconds = 600 ) $now = Get-PaUnixTime -Utc $NowUtc $header = [ordered]@{ alg = 'RS256'; typ = 'JWT'; x5t = (ConvertTo-PaB64Url $Certificate.GetCertHash()) } $claims = [ordered]@{ aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" iss = $ClientId sub = $ClientId jti = [guid]::NewGuid().ToString() nbf = $now exp = $now + $LifetimeSeconds } $enc = [Text.Encoding]::UTF8 $unsigned = (ConvertTo-PaB64Url $enc.GetBytes(($header | ConvertTo-Json -Compress))) + '.' + (ConvertTo-PaB64Url $enc.GetBytes(($claims | ConvertTo-Json -Compress))) $sig = Invoke-PaRsaSha256Sign -Certificate $Certificate -Data $enc.GetBytes($unsigned) $unsigned + '.' + (ConvertTo-PaB64Url $sig) } # App-only Graph token: client_credentials with the certificate assertion. Returns the token response. function Get-PaAppOnlyToken { param([Parameter(Mandatory)][string]$TenantId, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$CertificateThumbprint) $cert = Get-PaCertificate -Thumbprint $CertificateThumbprint Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ client_id = $ClientId grant_type = 'client_credentials' scope = 'https://graph.microsoft.com/.default' client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' client_assertion = (New-PaClientAssertion -Certificate $cert -ClientId $ClientId -TenantId $TenantId) } } # Active directory role template ids from the session token's wids claim (the user's ACTIVE roles at issuance, readable # without any directory-read scope -- unlike /me/memberOf, which hides roles from lean tokens). $null = unknown (no # token, or no wids claim), not "no roles". function Get-PaTokenRoleIds { if ($script:PaTokenClaims -and $script:PaTokenClaims.PSObject.Properties['wids']) { return @($script:PaTokenClaims.wids) } $null } # Ensure the session token carries at least one of the given directory role template ids. Typical failure: the operator # activated the PIM role AFTER signing in, so the token predates the activation -- then run -Reconnect ONCE to mint a # fresh token, and re-check. Without -Reconnect (a token passed in, which cannot be renewed here) it stops with the # fix. -SoftFail warns and continues instead of throwing. No-op when the session's roles cannot be determined. function Assert-PaSessionRole { param( [Parameter(Mandatory)][string[]]$AnyOfRoleIds, [Parameter(Mandatory)][string]$RoleDescription, [scriptblock]$Reconnect, [switch]$SoftFail ) $wids = Get-PaTokenRoleIds if ($null -eq $wids) { return } # cannot introspect -- let the API be the judge if (@($wids | Where-Object { $_ -in $AnyOfRoleIds }).Count -gt 0) { return } Write-Host "Session token does not carry $RoleDescription." -ForegroundColor Yellow if (-not $Reconnect) { if ($SoftFail) { Write-Host 'Continuing anyway (expect a 403 if the role is really missing).' -ForegroundColor DarkYellow; return } throw "The access token does not carry $RoleDescription. Activate the role in PIM, mint a NEW token after the activation, and run this script again." } Write-Host 'If you activated it in PIM after signing in, the token predates the activation -- signing in again to pick it up...' -ForegroundColor Yellow & $Reconnect $wids = Get-PaTokenRoleIds if ($null -eq $wids -or @($wids | Where-Object { $_ -in $AnyOfRoleIds }).Count -gt 0) { return } if ($SoftFail) { Write-Host "Still no $RoleDescription in the fresh token -- continuing anyway (a scoped RBAC assignment may still authorize the writes; expect a 403 otherwise)." -ForegroundColor DarkYellow return } throw "Even after a fresh sign-in, the session does not carry $RoleDescription. Activate it in PIM, wait for the activation to complete, then re-run this script." } # One-stop sign-in. Establishes the session token (Set-PaGraphSession) and returns a context object: # TenantId, Account, ClientId, AuthType (Interactive | AppOnly | ProvidedToken), Scopes (delegated scp), Roles (app roles). # * -AccessToken : use that token as is (no renewal; scope check reported, not enforced). # * -AppId + -CertificateThumbprint (+ -TenantId) : app-only; the app needs the equivalent APPLICATION permissions # (Application.ReadWrite.All; DelegatedPermissionGrant.ReadWrite.All for consent grants). # * otherwise : interactive browser sign-in (Edge unless -UseEdge:$false). # Then a cheap probe (/me, or one application for app-only) proves the token works before any write. function Connect-PimActivatorGraph { param( [Parameter(Mandatory)][string[]]$RequiredScopes, [string]$TenantId, [bool]$UseEdge = $true, [string]$AppId, [string]$CertificateThumbprint, [string]$AccessToken ) $isGuid = { param($s) $s -match '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$' } if ($AccessToken) { Set-PaGraphSession -AccessToken $AccessToken -AuthType ProvidedToken Write-Host 'Using the access token passed with -AccessToken (no renewal -- valid ~1 hour from when it was minted).' -ForegroundColor Cyan } elseif ($AppId -and $CertificateThumbprint) { if (-not $TenantId) { throw 'App-only sign-in requires -TenantId.' } $recipe = @{ Kind = 'AppOnly'; TenantId = $TenantId; ClientId = $AppId; Thumbprint = $CertificateThumbprint } try { $tok = Get-PaAppOnlyToken -TenantId $TenantId -ClientId $AppId -CertificateThumbprint $CertificateThumbprint } catch { throw "App-only token request failed (app $AppId, certificate $CertificateThumbprint, tenant $TenantId): $($_.Exception.Message)" } Set-PaGraphSession -AccessToken "$($tok.access_token)" -AuthType AppOnly -ExpiresInSeconds ([int]"0$($tok.expires_in)") -Refresh $recipe } else { $tenant = if ($TenantId) { $TenantId } else { 'organizations' } Write-Host "Signing in to Microsoft Graph in the browser (scopes: $($RequiredScopes -join ', '))..." -ForegroundColor Yellow try { $tok = Get-PaInteractiveToken -Scopes $RequiredScopes -Tenant $tenant -UseEdge $UseEdge } catch { throw ("Sign-in failed: $($_.Exception.Message)`n$(Get-PaBrokenAuthHelp)") } $refresh = $null if ($tok.refresh_token) { $refresh = @{ Kind = 'RefreshToken'; RefreshToken = "$($tok.refresh_token)"; Tenant = $tenant; Scopes = $RequiredScopes } } Set-PaGraphSession -AccessToken "$($tok.access_token)" -AuthType Interactive -ExpiresInSeconds ([int]"0$($tok.expires_in)") -Refresh $refresh Write-Host 'Signed in (token valid ~1 hour, renewed automatically).' -ForegroundColor Green } $c = $script:PaTokenClaims $ctx = [pscustomobject]@{ TenantId = $(if ($c -and $c.PSObject.Properties['tid']) { "$($c.tid)" } elseif (& $isGuid $TenantId) { $TenantId } else { $null }) Account = $(if ($c) { foreach ($n in 'upn', 'preferred_username', 'unique_name', 'app_displayname') { if ($c.PSObject.Properties[$n] -and $c.$n) { "$($c.$n)"; break } } }) ClientId = $(if ($c -and $c.PSObject.Properties['appid']) { "$($c.appid)" } elseif ($c -and $c.PSObject.Properties['azp']) { "$($c.azp)" } else { $AppId }) AuthType = $script:PaAuthType Scopes = @($(if ($c -and $c.PSObject.Properties['scp']) { "$($c.scp)" -split ' ' | Where-Object { $_ } })) Roles = @($(if ($c -and $c.PSObject.Properties['roles']) { $c.roles })) } if ($TenantId -and (& $isGuid $TenantId) -and $ctx.TenantId -and $ctx.TenantId -ne $TenantId) { throw "Signed in to tenant $($ctx.TenantId) but -TenantId says $TenantId. Sign in to the correct tenant (or fix -TenantId)." } if ($ctx.AuthType -eq 'AppOnly') { try { Invoke-PaGraph -Method GET -Path '/applications?$top=1&$select=id' | Out-Null } catch { throw "App-only token minted but a test read failed: $($_.Exception.Message)" } Write-Host "Connected app-only as appId $($ctx.ClientId) in tenant $($ctx.TenantId)." -ForegroundColor Green return $ctx } if ($ctx.AuthType -eq 'ProvidedToken' -and $ctx.Roles.Count -gt 0 -and $ctx.Scopes.Count -eq 0) { Write-Host 'The -AccessToken is an app-only token: skipping the delegated scope check.' -ForegroundColor DarkYellow try { Invoke-PaGraph -Method GET -Path '/applications?$top=1&$select=id' | Out-Null } catch { throw "The -AccessToken was rejected: $($_.Exception.Message)" } return $ctx } $missing = @($RequiredScopes | Where-Object { $_ -notin $ctx.Scopes }) if ($missing.Count -gt 0 -and $ctx.Scopes.Count -gt 0) { $msg = "The token is missing required delegated scopes: $($missing -join ', ')." if ($ctx.AuthType -eq 'Interactive') { throw "$msg Consent to them for 'Microsoft Graph Command Line Tools' (an administrator may have to grant it) and run again." } Write-Host "$msg Continuing -- calls that need them will fail with 403." -ForegroundColor DarkYellow } try { $me = Invoke-PaGraph -Method GET -Path '/me?$select=id,userPrincipalName' if (-not $ctx.Account -and $me) { $ctx.Account = "$($me.userPrincipalName)" } } catch { if ($ctx.AuthType -eq 'ProvidedToken') { throw ("The -AccessToken was rejected: $($_.Exception.Message)`nTokens expire after ~1 hour -- mint a fresh one.") } throw ("Signed in, but Microsoft Graph rejected the token: $($_.Exception.Message)`n$(Get-PaBrokenAuthHelp)") } $ctx } # ===== END inlined helper: _PimActivatorAuth.ps1 ===== # Graph REST seam (Invoke-PaGraph) + the app reg / SP / consent-grant body builders. # ===== BEGIN inlined helper: _PimActivatorBackend.ps1 ===== # _PimActivatorBackend.ps1 -- Graph REST seam + write helpers for the PIM Activator backend deploy (app registration + # service principal + delegated admin-consent grants). Dot-source from Deploy-PimActivatorBackend.ps1; defines # functions only (no side effects on load). # # NO MODULES (owner 2026-10-07: "neither pim, si or invardia must have dependencies"): every Graph call goes through # Invoke-PaGraph -- plain Invoke-RestMethod with the session token that _PimActivatorAuth.ps1 establishes # (Set-PaGraphSession). Nothing from the engine tree is loaded either: the deploy is published as ONE standalone file # (tools/setup/Build-PimSupportScripts.ps1), which cannot carry the engine. Pure builders (body shapes, scope strings, # scope-id resolution) are unit-tested offline. # The session token, renewed through Update-PaGraphSession (_PimActivatorAuth.ps1) when it is within 5 minutes of # expiry and a renewal recipe exists. Throws when no one has signed in. function Get-PaGraphAccessToken { if (-not $script:PaGraphToken) { throw 'Not signed in to Microsoft Graph (no session token) -- sign in first (Connect-PimActivatorGraph).' } if ($script:PaGraphTokenRefresh -and $script:PaGraphTokenExpiresUtc -and [datetime]::UtcNow -gt ([datetime]$script:PaGraphTokenExpiresUtc).AddMinutes(-5) -and (Get-Command Update-PaGraphSession -ErrorAction SilentlyContinue)) { Update-PaGraphSession } $script:PaGraphToken } # "https://graph.microsoft.com/v1.0/" (or beta) from a v1.0/beta-relative path; absolute URLs (an # @odata.nextLink) pass through, "v1.0/..." / "beta/..." prefixes are honoured. function Resolve-PaGraphUri { param([Parameter(Mandatory)][string]$Path, [switch]$Beta) if ($Path -match '^https?://') { return $Path } $rel = $Path.TrimStart('/') if ($rel -match '^(v1\.0|beta)/') { return "https://graph.microsoft.com/$rel" } $ver = if ($Beta) { 'beta' } else { 'v1.0' } "https://graph.microsoft.com/$ver/$rel" } # HTTP status of a failed Invoke-RestMethod (5.1: WebException.Response; 7: HttpResponseException.Response), with the # message as the fallback ("(429)" in 5.1, "status code ... 429" in 7). 0 = unknown. function Get-PaHttpStatus { param($ErrorRecord) $ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } else { $ErrorRecord } try { if ($ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } } catch { } $m = "$($ex.Message)" if ($m -match '\((\d{3})\)') { return [int]$Matches[1] } if ($m -match 'status code[^0-9]{0,40}(\d{3})') { return [int]$Matches[1] } 0 } # Seconds from a Retry-After header on a failed request, or $null. function Get-PaRetryAfterSeconds { param($ErrorRecord) $ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } else { $ErrorRecord } try { $h = $ex.Response.Headers if ($null -eq $h) { return $null } if ($h.PSObject.Properties['RetryAfter'] -and $h.RetryAfter) { # 7: HttpResponseHeaders if ($h.RetryAfter.Delta) { return [int][Math]::Ceiling($h.RetryAfter.Delta.TotalSeconds) } if ($h.RetryAfter.Date) { return [int][Math]::Max(1, [Math]::Ceiling(($h.RetryAfter.Date.UtcDateTime - [datetime]::UtcNow).TotalSeconds)) } } $v = $null try { $v = $h['Retry-After'] } catch { } # 5.1: WebHeaderCollection if ($v -and "$v" -match '^\d+$') { return [int]"$v" } } catch { } $null } # The Graph error body (JSON) of a failed request, best effort: ": ". function Get-PaGraphErrorText { param($ErrorRecord) $raw = $null if ($ErrorRecord -is [System.Management.Automation.ErrorRecord] -and $ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { $raw = $ErrorRecord.ErrorDetails.Message } if (-not $raw) { try { $resp = $ErrorRecord.Exception.Response if ($resp -and ($resp | Get-Member -Name GetResponseStream -MemberType Method)) { $raw = (New-Object System.IO.StreamReader($resp.GetResponseStream())).ReadToEnd() } } catch { } } if (-not $raw) { return "$($ErrorRecord.Exception.Message)" } try { $j = $raw | ConvertFrom-Json if ($j.error) { return "$($j.error.code): $($j.error.message)" } } catch { } "$raw" } # The single Graph seam used by every backend call. -Path is a v1.0/beta-relative Graph path (e.g. # "/applications?`$filter=...") or an absolute URL. JSON bodies are sent as UTF-8 bytes. Collections come back as the # `value` items (all pages with -All, following @odata.nextLink); single resources as the object. 429/503/504 are # retried (Retry-After honoured, max -MaxRetries). Any other failure throws "Graph failed: HTTP # : " -- so a 404 carries both "404" and "Request_ResourceNotFound" for the callers' not-found checks. function Invoke-PaGraph { param( [string]$Method = 'GET', [Parameter(Mandatory)][string]$Path, [object]$Body, [switch]$Beta, [switch]$All, [int]$MaxRetries = 5 ) $uri = Resolve-PaGraphUri -Path $Path -Beta:$Beta $bytes = $null if ($null -ne $Body) { $json = if ($Body -is [string]) { $Body } else { ConvertTo-Json -InputObject $Body -Depth 12 } $bytes = [Text.Encoding]::UTF8.GetBytes($json) } $send = { param([string]$M, [string]$U, [byte[]]$B) for ($try = 0; ; $try++) { $p = @{ Method = $M; Uri = $U; Headers = @{ Authorization = "Bearer $(Get-PaGraphAccessToken)" }; ErrorAction = 'Stop' } if ($null -ne $B) { $p.Body = $B; $p.ContentType = 'application/json; charset=utf-8' } try { return Invoke-RestMethod @p } catch { $code = Get-PaHttpStatus $_ if (($code -in @(429, 503, 504)) -and ($try -lt $MaxRetries)) { $wait = Get-PaRetryAfterSeconds $_ if (-not $wait -or $wait -lt 1) { $wait = [int][Math]::Min(60, [Math]::Pow(2, $try + 1)) } Write-Host " Graph answered HTTP $code -- retry $($try + 1)/$MaxRetries in $wait s" -ForegroundColor DarkGray Start-Sleep -Seconds $wait continue } $codeText = if ($code) { "HTTP $code " } else { '' } throw "Graph $M $U failed: $codeText$(Get-PaGraphErrorText $_)" } } } $resp = & $send $Method $uri $bytes $isCollection = { param($r) if ($null -eq $r) { return $false } if ($r -is [System.Collections.IDictionary]) { return $r.Contains('value') -and ($null -eq $r['value'] -or $r['value'] -is [array]) } $vp = $r.PSObject.Properties['value'] [bool]($vp -and ($null -eq $vp.Value -or $vp.Value -is [array])) } if (-not (& $isCollection $resp)) { return $resp } $items = New-Object System.Collections.Generic.List[object] $page = $resp while ($true) { foreach ($i in @(Get-PaProp $page 'value')) { if ($null -ne $i) { $items.Add($i) } } if (-not $All) { break } $next = Get-PaProp $page '@odata.nextLink' if (-not $next) { break } $page = & $send 'GET' "$next" $null } $items.ToArray() } # Read a property off a Graph object case-insensitively (REST returns camelCase 'appId'/'id'; hashtables and older # objects may carry PascalCase). Returns $null when absent. -Name is the canonical camelCase key (e.g. 'appId', 'id'). function Get-PaProp { param([AllowNull()]$Object, [Parameter(Mandatory)][string]$Name) if ($null -eq $Object) { return $null } $pascal = $Name.Substring(0,1).ToUpperInvariant() + $Name.Substring(1) if ($Object -is [System.Collections.IDictionary]) { foreach ($k in @($Name, $pascal)) { if ($Object.Contains($k)) { return $Object[$k] } } return $null } foreach ($k in @($Name, $pascal)) { $p = $Object.PSObject.Properties[$k] if ($p) { return $p.Value } } $null } # Idempotently create-or-update an AllPrincipals oauth2PermissionGrant (tenant- # wide admin consent) from one client SP to one resource SP, over Graph REST via # Invoke-PaGraph (GET by filter, then PATCH or POST). -ClientSpId/-ResourceSpId are SP object ids; -Scope a space-delimited # scope string. Returns nothing; writes a status line. function Set-PaOauth2Grant { param( [Parameter(Mandatory)][string]$ClientSpId, [Parameter(Mandatory)][string]$ResourceSpId, [Parameter(Mandatory)][string]$Scope, [string]$Label = 'consent' ) $filter = "clientId eq '$ClientSpId' and consentType eq 'AllPrincipals' and resourceId eq '$ResourceSpId'" $existing = @(Invoke-PaGraph -Method GET -Path "/oauth2PermissionGrants?`$filter=$([uri]::EscapeDataString($filter))" -All) if ($existing.Count -gt 0) { $gid = Get-PaProp $existing[0] 'id' Invoke-PaGraph -Method PATCH -Path "/oauth2PermissionGrants/$gid" -Body @{ scope = $Scope } | Out-Null Write-Host " updated existing $Label consent grant" -ForegroundColor DarkGray } else { Invoke-PaGraph -Method POST -Path '/oauth2PermissionGrants' -Body @{ clientId = $ClientSpId; consentType = 'AllPrincipals'; resourceId = $ResourceSpId; scope = $Scope } | Out-Null Write-Host " created new $Label consent grant" -ForegroundColor DarkGray } } # --------------------------------------------------------------------------- # Pure builders (no network) -- unit-tested in PIM.Activator.Tests.ps1 # --------------------------------------------------------------------------- # Resolve a set of delegated permission VALUES to their scope ids from a Graph # servicePrincipal's oauth2PermissionScopes collection. Returns an ordered map # value -> id; throws on any value not exposed by the SP (so a typo / removed # scope fails loud instead of silently dropping a permission). function Resolve-PaGraphScopeIds { param( [Parameter(Mandatory)]$Oauth2PermissionScopes, # array of objects with .value/.Value + .id/.Id [Parameter(Mandatory)][string[]]$Names ) $byVal = @{} foreach ($s in @($Oauth2PermissionScopes)) { $v = if ($null -ne $s.value) { $s.value } else { $s.Value } $i = if ($null -ne $s.id) { $s.id } else { $s.Id } if ($v) { $byVal["$v"] = "$i" } } $out = [ordered]@{} foreach ($n in $Names) { if (-not $byVal.ContainsKey($n)) { throw "Delegated scope '$n' not found on the service principal." } $out[$n] = $byVal[$n] } $out } # Build the requiredResourceAccess block (Graph delegated scopes + ASM # user_impersonation) as the plain REST shape Graph expects on # applications create/PATCH. -GraphScopeIds is the ordered map from # Resolve-PaGraphScopeIds; -AsmScopeId the ASM user_impersonation scope id. function New-PaRequiredResourceAccess { param( [Parameter(Mandatory)][string]$GraphAppId, [Parameter(Mandatory)]$GraphScopeIds, # ordered map name->id (or hashtable) [Parameter(Mandatory)][string]$AsmAppId, [Parameter(Mandatory)][string]$AsmScopeId ) $graphAccess = @() foreach ($k in $GraphScopeIds.Keys) { $graphAccess += @{ id = "$($GraphScopeIds[$k])"; type = 'Scope' } } @( @{ resourceAppId = $GraphAppId; resourceAccess = $graphAccess }, @{ resourceAppId = $AsmAppId; resourceAccess = @(@{ id = "$AsmScopeId"; type = 'Scope' }) } ) } # Build the application create/update body for the activator SPA app. Modern # Edge/Chrome MV3 auth needs BOTH SPA redirect URIs: the chromiumapp.org # redirect and the chrome-extension origin (Entra validates Origin on token # redemption). Public-client URIs are explicitly cleared. function New-PaAppRegistrationBody { param( [Parameter(Mandatory)][string]$DisplayName, [Parameter(Mandatory)][string]$ExtensionId, [Parameter(Mandatory)]$RequiredResourceAccess, # Extra extension ids to ALSO register redirect URIs for (e.g. the TEST # channel id alongside the RELEASED id) so one app reg serves multiple # PIM Activator builds side-by-side. [string[]]$AdditionalExtensionIds = @(), # Pre-existing SPA redirect URIs to PRESERVE (union), so updating an app to # "accept this as well" never drops URIs already configured in the tenant. [string[]]$ExistingSpaRedirectUris = @(), [switch]$IncludeDisplayName ) # Both forms per id: https://.chromiumapp.org/ (launchWebAuthFlow redirect) # + chrome-extension:/// (Origin for SPA token redemption). $ids = @(@($ExtensionId) + @($AdditionalExtensionIds)) | Where-Object { $_ } | Select-Object -Unique $uris = @($ExistingSpaRedirectUris) foreach ($id in $ids) { $uris += "https://$id.chromiumapp.org/"; $uris += "chrome-extension://$id/" } $body = @{ signInAudience = 'AzureADMyOrg' spa = @{ redirectUris = @($uris | Where-Object { $_ } | Select-Object -Unique) } publicClient = @{ redirectUris = @() } isFallbackPublicClient = $false requiredResourceAccess = $RequiredResourceAccess } if ($IncludeDisplayName) { $body.displayName = $DisplayName } $body } # The space-delimited scope string written into an oauth2PermissionGrant. Always # appends the OIDC basics + offline_access so tenants with restrictive user- # consent settings sign users in silently (matches the previous behaviour). function New-PaConsentScopeString { param([Parameter(Mandatory)][string[]]$Scopes) (@($Scopes) + @('openid','profile','offline_access') | Select-Object -Unique) -join ' ' } # ===== END inlined helper: _PimActivatorBackend.ps1 ===== # 12.7 run frame: "