diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1 index 6a1ce86..3a379a9 100644 --- a/setup/Setup-Epp.ps1 +++ b/setup/Setup-Epp.ps1 @@ -11,6 +11,8 @@ Public GitHub owner/repository containing the setup files. Use with SourceRef to test a fork. .PARAMETER InstallPrerequisites Install missing Microsoft Graph modules and the Azure CLI Bicep component after explicit opt-in. +.PARAMETER ForceAuthentication + Require fresh tenant-specific device-code sign-in for Azure CLI and Microsoft Graph. .EXAMPLE .\Setup-Epp.ps1 .EXAMPLE @@ -34,6 +36,7 @@ param( [string] $SourceRef = 'main', [switch] $NonInteractive, [switch] $InstallPrerequisites, + [switch] $ForceAuthentication, [switch] $ApproveDeployment ) diff --git a/setup/docs/README.md b/setup/docs/README.md index a583e87..b0cb168 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -65,8 +65,10 @@ disclosed outbound managed-identity federated credential. - An Azure **user** account permitted to deploy at subscription scope, create the listed resources, and create the scoped Azure role assignments. - A Microsoft Entra **Privileged Role Administrator** for granting the Microsoft first-party service - principal Graph `Application.Read.All`, plus delegated Graph scopes `Application.ReadWrite.All`, - `Application.Read.All`, and `AppRoleAssignment.ReadWrite.All`. + principal Graph `Application.Read.All`, plus delegated Graph scopes `User.Read`, + `Application.ReadWrite.All`, `Application.Read.All`, and `AppRoleAssignment.ReadWrite.All`. + `User.Read` is for the setup operator's `/me` lookup; it is not granted to the first-party service + principal or the endpoint app. - Microsoft Graph **beta** access for the Entra `signInAudienceRestrictions` allowed-tenants preview. The selected provider tenant is allowed in addition to the app's home tenant, which Entra always allows. - **Linux Premium EP1** available in the chosen region. Setup registers missing required Azure @@ -96,11 +98,17 @@ Setup normally detects these automatically. For unattended execution, allow inst Install Azure CLI through its official installation instructions if necessary. Setup checks the explicitly supplied subscription and tenant without changing the CLI's selected subscription. If no matching Azure user session exists, it runs `az login --tenant `. It separately requests -Graph sign-in before displaying the plan if the delegated session is missing required scopes. The +Graph sign-in before displaying the plan if the delegated session is missing required scopes, +including `User.Read` for operator identity readback. The consent includes broad app-role-management scopes because the approved deployment grants `Application.Read.All` to the Microsoft phone-provider service principal. Authentication, module installation, Bicep installation, MFA, and consent prompts are not resource-creation approvals. +Use `-ForceAuthentication` when the machine has ambiguous cached identities. It requires interactive +device-code authentication for Azure CLI and Microsoft Graph, does not clear shared token caches, +and cannot be combined with `-NonInteractive`. Azure RBAC always uses the selected ARM token's +validated `oid`; Graph `/me` is tracked separately for application-management operations. + ## Step 2 - download and run one script Download and inspect [Setup-Epp.ps1](../Setup-Epp.ps1), or save it from the upstream raw URL: @@ -112,6 +120,12 @@ Invoke-WebRequest ` .\Setup-Epp.ps1 ``` +Force explicit account selection when testing on a shared or multi-account computer: + +```powershell +.\Setup-Epp.ps1 -ForceAuthentication +``` + The flow is: 1. **Collect missing customer inputs:** tenant, subscription, existing application client ID, Azure diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index a51ae0b..a38e9a4 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -139,7 +139,7 @@ setup makes it multi-tenant, restricts it to its home tenant plus the provider J through the Entra allowed-tenants preview, creates both required service principals, adds and assigns `Epp.Invoke`, and grants the Microsoft phone-provider service principal Graph `Application.Read.All`. -Graph needs delegated `Application.ReadWrite.All`, `Application.Read.All`, and +Graph needs delegated `User.Read`, `Application.ReadWrite.All`, `Application.Read.All`, and `AppRoleAssignment.ReadWrite.All`. Granting a Microsoft Graph application permission normally requires a Privileged Role Administrator. Noninteractive runs must authenticate both clients first with these scopes and supply `-ApproveDeployment` separately. @@ -147,6 +147,40 @@ with these scopes and supply `-ApproveDeployment` separately. The tenant restriction uses Microsoft Graph beta `signInAudienceRestrictions`. If that preview is unavailable or the tenant policy blocks it, setup stops before mutation rather than silently allowing all organizational tenants. + +## Graph /me returns 403 Forbidden + +`GET /me?$select=id,userPrincipalName` requires delegated +[`User.Read`](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0#permissions). +The application-management scopes do not authorize this profile lookup. Versions that added the +Graph operator readback without requesting `User.Read` could therefore fail during preflight. +This is a setup sign-in scope issue, not a request for another Azure or Entra administrator role. + +Use the updated script and source revision. It requests `User.Read` for the operator's Graph +PowerShell session and reconnects interactively when a cached session lacks it, before calling +`/me`. `-ForceAuthentication` also requests the complete scope set. Noninteractive runs must +authenticate first: + +```powershell +Connect-MgGraph -TenantId '' -ContextScope Process ` + -Scopes 'User.Read', 'Application.ReadWrite.All', 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All' +``` + +This does not grant `User.Read` to the Microsoft phone-provider service principal or endpoint app. +Azure role assignments continue to use the ARM token's `oid`, not Graph `/me`. + +## PrincipalNotFound for the Azure operator + +Azure RBAC and Microsoft Graph can expose different object IDs for the same interactive account, +especially with brokered, guest, or aliased identities. Setup must not use Graph `/me` as an Azure +role-assignment principal. The current script decodes the selected subscription's ARM access token +in memory, validates its tenant, and passes its `oid` to Bicep. The token is never printed or saved. + +Use `-ForceAuthentication` to require fresh Azure CLI and Graph device-code sign-in when account +selection is ambiguous. This does not replace ARM-token identity selection and does not run +`az logout`, `az account clear`, or delete shared authentication caches. If a correct ARM `oid` +still receives `PrincipalNotFound`, wait for actual directory/RBAC replication and rerun with the +same prefix; retries must not substitute a Graph object ID. Use a distinct resource prefix for each language; setup rejects changing a previously tagged app to another runtime with the same prefix. diff --git a/setup/providers/telesign.json b/setup/providers/telesign.json index ab47f65..190269e 100644 --- a/setup/providers/telesign.json +++ b/setup/providers/telesign.json @@ -12,13 +12,13 @@ "routes": { "sms": { "global": { - "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/sms", + "endpoint": "https://verify.telesign.com/integration/msft/cyot", "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 }, "eu": { - "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/sms", + "endpoint": "https://verify.telesign.com/integration/msft/cyot", "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 @@ -26,19 +26,19 @@ }, "voice": { "global": { - "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/voice", + "endpoint": "https://verify.telesign.com/integration/msft/cyot", "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 }, "eu": { - "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/voice", + "endpoint": "https://verify.telesign.com/integration/msft/cyot", "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 } } }, - "note": "The supplied global SMS and voice URLs are preserved. EU URLs and application IDs are explicit test values until Telesign provides them." + "note": "All Telesign channel and region selections use the supplied Microsoft CYOT integration endpoint. Application IDs remain explicit test values until Telesign provides them." } } diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 index 652c344..fe10e2c 100644 --- a/setup/support/Epp.Setup.psm1 +++ b/setup/support/Epp.Setup.psm1 @@ -6,7 +6,7 @@ $script:MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' $script:MicrosoftGraphApplicationReadAllRoleId = '9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30' $script:EppInvokeAppRoleId = 'ddf32018-9212-41c7-b73c-f5dfe73a2f24' $script:EppInvokeAppRoleValue = 'Epp.Invoke' -$script:GraphRequiredScopes = @('Application.ReadWrite.All', 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All') +$script:GraphRequiredScopes = @('User.Read', 'Application.ReadWrite.All', 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All') . (Join-Path $PSScriptRoot 'Epp.Packages.ps1') function Read-EppJson { @@ -399,19 +399,27 @@ function Initialize-EppBicep { } function Connect-EppAzureAccount { - param([hashtable] $Inputs, [switch] $NonInteractive) + param([hashtable] $Inputs, [switch] $NonInteractive, [switch] $ForceAuthentication) $account = $null - try { + if ($ForceAuthentication) { + if ($NonInteractive) { throw '-ForceAuthentication cannot be combined with -NonInteractive.' } + Write-Host "Reauthenticating Azure CLI to tenant $($Inputs.TenantId) with device code..." -ForegroundColor Cyan + Invoke-EppAz login --tenant $Inputs.TenantId --use-device-code --output none | Out-Null $account = Invoke-EppAz account show --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json } - catch { - if ($NonInteractive) { - throw "Azure CLI is not signed in to subscription '$($Inputs.SubscriptionId)' in tenant '$($Inputs.TenantId)'. Run az login first." + else { + try { + $account = Invoke-EppAz account show --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json + } + catch { + if ($NonInteractive) { + throw "Azure CLI is not signed in to subscription '$($Inputs.SubscriptionId)' in tenant '$($Inputs.TenantId)'. Run az login first." + } + Write-Host "Signing in to Azure tenant $($Inputs.TenantId)..." -ForegroundColor Cyan + Invoke-EppAz login --tenant $Inputs.TenantId --output none | Out-Null + $account = Invoke-EppAz account show --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json } - Write-Host "Signing in to Azure tenant $($Inputs.TenantId)..." -ForegroundColor Cyan - Invoke-EppAz login --tenant $Inputs.TenantId --output none | Out-Null - $account = Invoke-EppAz account show --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json } if ($account.id -ne $Inputs.SubscriptionId -or $account.tenantId -ne $Inputs.TenantId -or $account.state -ne 'Enabled' -or $account.environmentName -ne 'AzureCloud' -or $account.user.type -ne 'user') { @@ -420,6 +428,81 @@ function Connect-EppAzureAccount { return $account } +function ConvertFrom-EppJwtPayload { + param([string] $Token) + + $segments = $Token.Split('.') + if ($segments.Count -ne 3 -or $segments[1].Length -gt 65536) { + throw 'Azure CLI returned an invalid ARM access token.' + } + $payload = $segments[1].Replace('-', '+').Replace('_', '/') + switch ($payload.Length % 4) { + 2 { $payload += '==' } + 3 { $payload += '=' } + 1 { throw 'Azure CLI returned an invalid ARM access-token payload.' } + } + try { + return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | + ConvertFrom-Json -AsHashtable -ErrorAction Stop + } + catch { + throw 'Azure CLI returned an unreadable ARM access-token payload.' + } +} + +function Get-EppArmOperatorObjectId { + param([hashtable] $Inputs) + + $token = $null + try { + $token = Invoke-EppAz account get-access-token --subscription $Inputs.SubscriptionId ` + --resource 'https://management.azure.com/' --query accessToken --output tsv + if ([string]::IsNullOrWhiteSpace($token)) { throw 'Azure CLI did not return an ARM access token.' } + $claims = ConvertFrom-EppJwtPayload -Token $token + if ($claims['tid'] -ne $Inputs.TenantId) { + throw 'The ARM access token belongs to a different tenant than the approved deployment.' + } + return ConvertTo-EppGuid ([string]$claims['oid']) + } + finally { $token = $null } +} + +function Get-EppGraphOperator { + $operator = Invoke-MgGraphRequest -Method GET ` + -Uri 'https://graph.microsoft.com/v1.0/me?$select=id,userPrincipalName' ` + -OutputType PSObject -ErrorAction Stop + $id = ConvertTo-EppGuid ([string]$operator.id) + $account = if ([string]::IsNullOrWhiteSpace([string]$operator.userPrincipalName)) { $id } else { [string]$operator.userPrincipalName } + return [pscustomobject]@{ Id = $id; Account = $account } +} + +function Connect-EppGraphAccount { + param([hashtable] $Inputs, [switch] $NonInteractive, [switch] $ForceAuthentication) + + if ($NonInteractive -and $ForceAuthentication) { + throw '-ForceAuthentication requires interactive Graph device-code sign-in and cannot be combined with -NonInteractive.' + } + $graph = if ($ForceAuthentication) { $null } else { Get-EppInitialGraphContext } + if ($ForceAuthentication -or -not (Test-EppGraphContext -Context $graph -TenantId $Inputs.TenantId)) { + $scopeList = $script:GraphRequiredScopes -join ', ' + if ($NonInteractive) { throw "Connect-MgGraph to the customer tenant with $scopeList before noninteractive setup." } + $connectArguments = @{ + TenantId = $Inputs.TenantId + Scopes = $script:GraphRequiredScopes + ContextScope = 'Process' + NoWelcome = $true + ErrorAction = 'Stop' + } + if ($ForceAuthentication) { $connectArguments.UseDeviceAuthentication = $true } + Connect-MgGraph @connectArguments + $graph = Get-MgContext -ErrorAction Stop + } + if (-not (Test-EppGraphContext -Context $graph -TenantId $Inputs.TenantId)) { + throw "Microsoft Graph is not connected to the required customer tenant with delegated scopes: $($script:GraphRequiredScopes -join ', ')." + } + return [pscustomobject]@{ Context = $graph; Operator = Get-EppGraphOperator } +} + function Get-EppInitialGraphContext { try { return Get-MgContext -ErrorAction Stop } catch { @@ -645,9 +728,12 @@ function Initialize-EppResourceProviders { function Connect-EppContext { param( [hashtable] $Inputs, [Collections.IDictionary] $Names, - [switch] $NonInteractive, [switch] $InstallPrerequisites + [switch] $NonInteractive, [switch] $InstallPrerequisites, [switch] $ForceAuthentication ) + if ($NonInteractive -and $ForceAuthentication) { + throw '-ForceAuthentication requires interactive device-code sign-in and cannot be combined with -NonInteractive.' + } foreach ($command in @('az', 'New-SelfSignedCertificate', 'Export-Certificate')) { if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { if ($command -eq 'az') { @@ -662,20 +748,13 @@ function Connect-EppContext { } Initialize-EppBicep -NonInteractive:$NonInteractive -InstallPrerequisites:$InstallPrerequisites Import-EppGraphModules -NonInteractive:$NonInteractive -InstallPrerequisites:$InstallPrerequisites - $account = Connect-EppAzureAccount -Inputs $Inputs -NonInteractive:$NonInteractive - $operatorId = Invoke-EppAz rest --method get --url 'https://graph.microsoft.com/v1.0/me' ` - --subscription $Inputs.SubscriptionId --query id --output tsv - $operatorId = ConvertTo-EppGuid $operatorId - $graph = Get-EppInitialGraphContext - if (-not (Test-EppGraphContext -Context $graph -TenantId $Inputs.TenantId)) { - $scopeList = $script:GraphRequiredScopes -join ', ' - if ($NonInteractive) { throw "Connect-MgGraph to the customer tenant with $scopeList before noninteractive setup." } - Connect-MgGraph -TenantId $Inputs.TenantId -Scopes $script:GraphRequiredScopes -ContextScope Process -NoWelcome -ErrorAction Stop - $graph = Get-MgContext -ErrorAction Stop - } - if (-not (Test-EppGraphContext -Context $graph -TenantId $Inputs.TenantId)) { - throw "Microsoft Graph is not connected to the required customer tenant with delegated scopes: $($script:GraphRequiredScopes -join ', ')." - } + $account = Connect-EppAzureAccount -Inputs $Inputs -NonInteractive:$NonInteractive ` + -ForceAuthentication:$ForceAuthentication + $operatorId = Get-EppArmOperatorObjectId -Inputs $Inputs + $graphAccount = Connect-EppGraphAccount -Inputs $Inputs -NonInteractive:$NonInteractive ` + -ForceAuthentication:$ForceAuthentication + $graph = $graphAccount.Context + $graphOperator = $graphAccount.Operator $applications = @(Get-MgApplication -Filter "appId eq '$($Inputs.ApplicationId)'" -All -ErrorAction Stop) if ($applications.Count -ne 1) { throw 'Complete manual Step 1: exactly one existing application with this client ID is required.' } $application = Get-MgApplication -ApplicationId $applications[0].Id ` @@ -734,7 +813,8 @@ function Connect-EppContext { } } return [pscustomobject]@{ - OperatorId = $operatorId; GraphAccount = $graph.Account; Application = $application; TokenVersion = $version + OperatorId = $operatorId; GraphOperatorId = $graphOperator.Id; GraphAccount = $graphOperator.Account + Application = $application; TokenVersion = $version EndpointPrincipal = $endpointPrincipals | Select-Object -First 1 CallerPrincipal = $callerPrincipals | Select-Object -First 1 GraphPrincipal = $graphPrincipals[0] @@ -779,8 +859,8 @@ function Show-EppPlan { Write-Host 'Registration and regional readiness are checked before certificate/resource creation. Existing or in-progress registrations are reused.' Write-Host 'Includes the private packages blob container, Function system identity, Easy Auth, and diagnostic settings.' Write-Host 'System identity: Storage Blob Data Owner, Queue/Table Data Contributor, Key Vault Secrets User, Monitoring Metrics Publisher.' - Write-Host "Azure operator $($Context.OperatorId): Key Vault Secrets Officer and Storage Blob Data Contributor, scoped to these resources." - Write-Host "Graph operator $($Context.GraphAccount): configure the dedicated endpoint app and tenant service principals." + Write-Host "Azure operator $($Context.OperatorId) (ARM token oid): Key Vault Secrets Officer and Storage Blob Data Contributor." + Write-Host "Graph operator $($Context.GraphAccount) [$($Context.GraphOperatorId)]: configure the dedicated endpoint app and tenant service principals." if ($Context.Application.SignInAudience -ne 'AzureADMultipleOrgs') { Write-Host 'Change the dedicated endpoint application from single-tenant to organizational multi-tenant.' } @@ -1231,8 +1311,8 @@ function Invoke-EppDeployment { ) $graph = Get-MgContext - if (-not (Test-EppGraphContext -Context $graph -TenantId $Inputs.TenantId) -or - $graph.Account -ne $Context.GraphAccount) { + $graphOperator = if (Test-EppGraphContext -Context $graph -TenantId $Inputs.TenantId) { Get-EppGraphOperator } else { $null } + if (-not $graphOperator -or $graphOperator.Id -ne $Context.GraphOperatorId) { throw 'The Graph session changed after the plan was reviewed. Rerun setup.' } # The single setup approval covers these planned writes, including SDK/certificate cmdlets. @@ -1368,7 +1448,8 @@ function Invoke-EppSetup { [string] $OutputDirectory, [string] $AssetDirectory, [string] $SourceBaseUri, [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', - [switch] $NonInteractive, [switch] $InstallPrerequisites, [switch] $ApproveDeployment + [switch] $NonInteractive, [switch] $InstallPrerequisites, + [switch] $ForceAuthentication, [switch] $ApproveDeployment ) Write-Host 'Step 2: deploy the External Phone Provider endpoint. Steps 1 and 3 are manual.' -ForegroundColor Cyan @@ -1394,7 +1475,7 @@ function Invoke-EppSetup { Write-Host "`nChecking prerequisites and the selected Azure context (no resource changes)..." -ForegroundColor Cyan $context = Connect-EppContext -Inputs $inputs -Names $names -NonInteractive:$NonInteractive ` - -InstallPrerequisites:$InstallPrerequisites + -InstallPrerequisites:$InstallPrerequisites -ForceAuthentication:$ForceAuthentication $package = Get-EppPackage -Selection $selection -Directory $AssetDirectory $inputs.PackageSha256 = $package.Sha256 $inputs.SourcePackageSha256 = $package.SourceSha256