#Requires -Version 7.0 <# .SYNOPSIS Creates DeskTrail's provider applications and imports credentials directly into its server. .DESCRIPTION One-time owner setup. Requires Microsoft.Graph.Authentication and permission to create apps. Central cloud setup, once for the provider. Customers connect through the website. Delegated read scopes are declared; this tool grants no customer mailbox permissions. Existing apps with matching names are never silently changed. Partial failures print only IDs. .EXAMPLE .\Initialize-DeskTrail.ps1 -InstallationUrl 'https://desktrail.rjit.dev' #> [CmdletBinding()] param( [Parameter(Mandatory)][uri]$InstallationUrl, [securestring]$SetupKey ) $ErrorActionPreference = 'Stop' if ($InstallationUrl.Scheme -ne 'https' -or $InstallationUrl.UserInfo -or $InstallationUrl.AbsolutePath -ne '/' -or $InstallationUrl.Query -or $InstallationUrl.Fragment) { throw 'InstallationUrl must be an HTTPS origin, for example https://desktrail.rjit.dev' } Import-Module Microsoft.Graph.Authentication -ErrorAction Stop if (!$SetupKey) { $SetupKey = Read-Host 'One-time DeskTrail setup key' -AsSecureString } $keyText = [System.Net.NetworkCredential]::new('', $SetupKey).Password if ($keyText -notmatch '^[a-f0-9]{64}$') { throw 'Invalid setup key format.' } $origin = $InstallationUrl.GetLeftPart([System.UriPartial]::Authority) $setupHeaders = @{ 'X-DeskTrail-Setup-Key' = $keyText } $createdApps = [System.Collections.Generic.List[object]]::new() $secret = $null $stage = 'Checking the server setup key' function Invoke-DeskTrailGraph([string]$Method, [string]$Path, [object]$Body = $null) { $argsForGraph = @{ Method = $Method; Uri = "https://graph.microsoft.com/v1.0/$Path"; OutputType = 'PSObject' } if ($null -ne $Body) { $argsForGraph.Body = ($Body | ConvertTo-Json -Depth 12 -Compress); $argsForGraph.ContentType = 'application/json' } Invoke-MgGraphRequest @argsForGraph } try { $null = Invoke-RestMethod -Method Post -Uri "$origin/api/setup/provider/check" -Headers $setupHeaders -MaximumRedirection 0 -TimeoutSec 30 $stage = 'Microsoft sign-in and app creation consent' Connect-MgGraph -Scopes @('Application.ReadWrite.All','User.Read') -ContextScope Process -NoWelcome $context = Get-MgContext $owner = Invoke-DeskTrailGraph Get 'me?$select=id,userPrincipalName' if (!$context.TenantId -or !$owner.id) { throw 'Microsoft identity is incomplete.' } $tag = "DeskTrail:$origin" $loginName = "DeskTrail Login ($($InstallationUrl.Host))" $workerName = "DeskTrail Sync ($($InstallationUrl.Host))" $stage = 'Checking for existing DeskTrail applications' foreach ($name in @($loginName, $workerName)) { $filter = [uri]::EscapeDataString("displayName eq '" + $name.Replace("'", "''") + "'") $existing = Invoke-DeskTrailGraph Get "applications?`$filter=$filter&`$select=id,appId,displayName" if (@($existing.value).Count -gt 0) { Write-Host "An app named '$name' already exists. Review it before retrying." throw 'Existing application collision.' } } $stage = 'Creating the login application' $loginApp = Invoke-DeskTrailGraph Post 'applications' @{ displayName = $loginName; signInAudience = 'AzureADMultipleOrgs'; tags = @($tag) spa = @{ redirectUris = @("$origin/auth-redirect.html", "brk-multihub://$($InstallationUrl.Authority)") } groupMembershipClaims = 'DirectoryRole' } $createdApps.Add(@{ Name = $loginName; ObjectId = $loginApp.id; ClientId = $loginApp.appId }) $scopeId = [guid]::NewGuid().ToString() $null = Invoke-DeskTrailGraph Patch "applications/$($loginApp.id)" @{ identifierUris = @("api://$($loginApp.appId)") api = @{ requestedAccessTokenVersion = 2 oauth2PermissionScopes = @(@{ id = $scopeId; value = 'access_as_user'; type = 'User'; isEnabled = $true userConsentDisplayName = 'Use DeskTrail'; userConsentDescription = 'Access DeskTrail as you. Your administrator controls ticket access.' adminConsentDisplayName = 'Use DeskTrail'; adminConsentDescription = 'Access DeskTrail ticket metadata as the signed-in user. No Microsoft mailbox access.' }) preAuthorizedApplications = @(@{appId = $loginApp.appId; delegatedPermissionIds = @($scopeId)}) } requiredResourceAccess = @(@{resourceAppId = $loginApp.appId; resourceAccess = @(@{id = $scopeId; type = 'Scope'})}) } $null = Invoke-DeskTrailGraph Post 'servicePrincipals' @{appId = $loginApp.appId} $stage = 'Creating the separate sync application' $workerApp = Invoke-DeskTrailGraph Post 'applications' @{ displayName = $workerName; signInAudience = 'AzureADMultipleOrgs'; tags = @($tag) web = @{ redirectUris = @("$origin/connect/microsoft/callback") } api = @{ requestedAccessTokenVersion = 2 } requiredResourceAccess = @(@{resourceAppId = '00000003-0000-0000-c000-000000000000'; resourceAccess = @( @{id = '7b9103a5-4610-446b-9670-80643382c1fa'; type = 'Scope'} @{id = 'e1fe6dd8-ba31-4d61-89e7-88639da4683d'; type = 'Scope'} )}) } $createdApps.Add(@{ Name = $workerName; ObjectId = $workerApp.id; ClientId = $workerApp.appId }) $workerPrincipal = Invoke-DeskTrailGraph Post 'servicePrincipals' @{appId = $workerApp.appId} $expires = [datetime]::UtcNow.AddDays(90).ToString('o') $stage = 'Creating and transferring the sync credential' $secret = Invoke-DeskTrailGraph Post "applications/$($workerApp.id)/addPassword" @{ passwordCredential = @{displayName = 'DeskTrail initial server credential'; endDateTime = $expires} } $payload = @{ tenant_id = $context.TenantId; client_id = $loginApp.appId; owner_id = $owner.id graph_client_id = $workerApp.appId; graph_client_secret = $secret.secretText service_object_id = $workerPrincipal.id; credential_expires_at = $expires } | ConvertTo-Json -Compress $null = Invoke-RestMethod -Method Post -Uri "$origin/api/setup/provider" -Headers $setupHeaders -Body $payload -ContentType 'application/json' -MaximumRedirection 0 -TimeoutSec 30 Write-Host 'DeskTrail provider setup completed. Mail access is still disabled.' Write-Host "Continue at $origin/setup" Write-Host "Sync credential expires: $expires. Arrange rotation before this date." } catch { Write-Host "Setup stopped during: $stage." if ($createdApps.Count) { Write-Host 'These applications may require review. They were not automatically deleted:'; $createdApps | ForEach-Object { Write-Host "$($_.Name) | Object ID: $($_.ObjectId) | Client ID: $($_.ClientId)" } } # Provider errors and request bodies may contain credentials. Never print the exception. throw 'DeskTrail setup was not completed. Check the indicated stage and use the provider guide.' } finally { $secret = $null; $payload = $null; $keyText = $null; $setupHeaders = $null Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }