MantraOutlookAddin.psm1
|
# # MantraOutlookAddin PowerShell Module # Creates the customer's own Entra app for the LibraCyber Outlook add-in # # Copyright (c) 2026 Mantra. All rights reserved. # $script:GraphAppId = '00000003-0000-0000-c000-000000000000' $script:GraphScopes = @('openid', 'profile', 'email') $script:AdminScopes = @('Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All') $script:OfficeClientIds = @( 'd3590ed6-52b3-4102-aeff-aad2292ab01c', '93d53678-613d-4013-afc1-62e9e444a0a5', '57fb890c-0dab-4253-a5e0-7188c88b2bb4', '08e18876-6177-487e-b8b5-cf950c1e598c', 'bc59ab01-8403-45c6-8796-ac3ef710b3e3', 'ea5a67f6-b6f3-4338-b240-c655ddc3cc8e' ) $script:AppDisplayName = 'LibraCyber Outlook add-in' $script:ScopeValue = 'access_as_user' $script:ReplicationDelaySeconds = 10 $script:Retries = 4 $script:VisibilityChecks = 12 $script:VisibilityDelaySeconds = 10 function Get-AddinHost { $addinHost = $ExecutionContext.SessionState.Module.PrivateData.AddinHost if ([string]::IsNullOrWhiteSpace($addinHost)) { throw "The module manifest has no PrivateData.AddinHost" } return $addinHost } function Get-SettingsUrl { $settingsUrl = $ExecutionContext.SessionState.Module.PrivateData.SettingsUrl if ([string]::IsNullOrWhiteSpace($settingsUrl)) { throw "The module manifest has no PrivateData.SettingsUrl" } return $settingsUrl.TrimEnd('/') } function Get-AddinAppTag { param([Parameter(Mandatory = $true)][string]$AddinHost) return "MantraOutlookAddin:$AddinHost" } function Select-AddinApp { param([object[]]$Apps) $found = @($Apps | Where-Object { $null -ne $_ }) if ($found.Count -gt 1) { $ids = ($found | ForEach-Object { $_.appId }) -join ', ' throw "Several apps are tagged for this add-in ($ids): delete the extra ones in Entra, then run this command again" } if ($found.Count -eq 1) { return $found[0] } return $null } function Get-GraphScopeIds { param([Parameter(Mandatory = $true)]$GraphServicePrincipal) $ids = @{} foreach ($scope in $script:GraphScopes) { $match = @($GraphServicePrincipal.oauth2PermissionScopes | Where-Object { $_.value -eq $scope }) if ($match.Count -ne 1) { throw "Microsoft Graph scope $scope not found in this tenant" } $ids[$scope] = $match[0].id } return $ids } function Get-ExistingAccessAsUserScope { param($App) if ($null -eq $App -or $null -eq $App.api) { return $null } $existing = @($App.api.oauth2PermissionScopes | Where-Object { $_.value -eq $script:ScopeValue }) if ($existing.Count -eq 1) { return $existing[0] } return $null } function Test-AccessAsUserScope { param($App) return $null -ne (Get-ExistingAccessAsUserScope -App $App) } function Get-AccessAsUserScopeId { param($App) $existing = Get-ExistingAccessAsUserScope -App $App if ($null -ne $existing) { return $existing.id } return [guid]::NewGuid().ToString() } function New-AccessAsUserScope { param([Parameter(Mandatory = $true)][string]$ScopeId) return @{ id = $ScopeId value = $script:ScopeValue type = 'User' isEnabled = $true adminConsentDisplayName = 'Access the add-in as the signed-in user' adminConsentDescription = 'Allows Office to call the LibraCyber add-in API as the signed-in user.' userConsentDisplayName = 'Access the add-in as you' userConsentDescription = 'Allows Office to call the LibraCyber add-in API as you.' } } function New-AddinAppCreation { param([Parameter(Mandatory = $true)][string]$AddinHost) return @{ displayName = $script:AppDisplayName signInAudience = 'AzureADMyOrg' tags = @(Get-AddinAppTag -AddinHost $AddinHost) } } function New-AddinApiPatch { param( [Parameter(Mandatory = $true)][string]$AddinHost, [Parameter(Mandatory = $true)][string]$AppId, [Parameter(Mandatory = $true)][string]$ScopeId, [Parameter(Mandatory = $true)][hashtable]$GraphScopeIds ) return @{ signInAudience = 'AzureADMyOrg' identifierUris = @("api://$AddinHost/$AppId") api = @{ requestedAccessTokenVersion = 2 oauth2PermissionScopes = @(New-AccessAsUserScope -ScopeId $ScopeId) } spa = @{ redirectUris = @("brk-multihub://$AddinHost") } requiredResourceAccess = @( @{ resourceAppId = $script:GraphAppId resourceAccess = @($script:GraphScopes | ForEach-Object { @{ id = $GraphScopeIds[$_]; type = 'Scope' } }) } ) optionalClaims = @{ accessToken = @(@{ name = 'email'; essential = $false; additionalProperties = @() }) } } } function New-AddinPreAuthorizationPatch { param([Parameter(Mandatory = $true)][string]$ScopeId) return @{ api = @{ requestedAccessTokenVersion = 2 oauth2PermissionScopes = @(New-AccessAsUserScope -ScopeId $ScopeId) preAuthorizedApplications = @($script:OfficeClientIds | ForEach-Object { @{ appId = $_; delegatedPermissionIds = @($ScopeId) } }) } } } function Invoke-Graph { param( [Parameter(Mandatory = $true)][string]$Method, [Parameter(Mandatory = $true)][string]$Path, $Body ) $params = @{ Method = $Method Uri = "https://graph.microsoft.com/v1.0$Path" ErrorAction = 'Stop' } if ($null -ne $Body) { $params['Body'] = ConvertTo-Json -InputObject $Body -Depth 10 -Compress $params['ContentType'] = 'application/json' } try { return Invoke-MgGraphRequest @params } catch { $status = Get-HttpErrorStatus -ErrorRecord $_ $detail = Get-HttpErrorDetail -ErrorRecord $_ $exception = [System.Exception]::new("Microsoft Graph refused $Method $(($Path -split '\?')[0]) (HTTP $status): $detail") $exception.Data['Status'] = $status throw $exception } } function Test-ReplicationError { param([Parameter(Mandatory = $true)][System.Exception]$Exception) $status = 0 if ($null -ne $Exception.Data['Status']) { $status = [int]$Exception.Data['Status'] } if ($status -eq 404 -or $status -eq 429 -or $status -ge 500) { return $true } return $status -eq 400 -and $Exception.Message -match 'does not exist|not found|cannot be found|Invalid object identifier|does not reference' } function Invoke-WithRetry { param( [Parameter(Mandatory = $true)][string]$Label, [Parameter(Mandatory = $true)][scriptblock]$Action ) for ($attempt = 1; ; $attempt++) { try { return & $Action } catch { if ($attempt -ge $script:Retries -or -not (Test-ReplicationError -Exception $_.Exception)) { throw } Write-Host " $Label failed (attempt $attempt), waiting for Entra replication..." -ForegroundColor Gray Start-Sleep -Seconds $script:ReplicationDelaySeconds } } } function Get-ODataFilter { param([Parameter(Mandatory = $true)][string]$Filter) return [uri]::EscapeDataString($Filter) } function Find-AddinApp { param([Parameter(Mandatory = $true)][string]$AddinHost) $tag = Get-AddinAppTag -AddinHost $AddinHost $filter = Get-ODataFilter "tags/any(t:t eq '$tag')" $result = Invoke-Graph -Method GET -Path "/applications?`$filter=$filter&`$select=id,appId,displayName,api" return Select-AddinApp -Apps @($result.value) } function Get-ServicePrincipalByAppId { param([Parameter(Mandatory = $true)][string]$AppId) $filter = Get-ODataFilter "appId eq '$AppId'" $result = Invoke-Graph -Method GET -Path "/servicePrincipals?`$filter=$filter&`$select=id,appId,oauth2PermissionScopes" $found = @($result.value) if ($found.Count -eq 0) { return $null } return $found[0] } function Set-AdminConsent { param( [Parameter(Mandatory = $true)][string]$ClientSpId, [Parameter(Mandatory = $true)][string]$GraphSpId ) $scope = $script:GraphScopes -join ' ' $filter = Get-ODataFilter "clientId eq '$ClientSpId' and consentType eq 'AllPrincipals' and resourceId eq '$GraphSpId'" $existing = @((Invoke-Graph -Method GET -Path "/oauth2PermissionGrants?`$filter=$filter").value) if ($existing.Count -eq 0) { Invoke-Graph -Method POST -Path '/oauth2PermissionGrants' -Body @{ clientId = $ClientSpId consentType = 'AllPrincipals' resourceId = $GraphSpId scope = $scope } | Out-Null return 'Granted' } if ($existing[0].scope -ne $scope) { Invoke-Graph -Method PATCH -Path "/oauth2PermissionGrants/$($existing[0].id)" -Body @{ scope = $scope } | Out-Null return 'Updated' } return 'Already granted' } function Get-HttpErrorDetail { param([Parameter(Mandatory = $true)]$ErrorRecord) $message = $null if ($null -ne $ErrorRecord.ErrorDetails) { $message = $ErrorRecord.ErrorDetails.Message } if (-not [string]::IsNullOrWhiteSpace($message)) { try { $body = ConvertFrom-Json -InputObject $message if ($body.detail -is [string] -and $body.detail -ne '') { return $body.detail } if ($null -ne $body.error -and $body.error.message -is [string] -and $body.error.message -ne '') { return $body.error.message } } catch { Write-Verbose "The error body isn't JSON: $($_.Exception.Message)" } return $message } return $ErrorRecord.Exception.Message } function Get-HttpErrorStatus { param([Parameter(Mandatory = $true)]$ErrorRecord) $response = $ErrorRecord.Exception.Response if ($null -eq $response) { return 0 } return [int]$response.StatusCode } function Enable-Tls12 { if ($PSVersionTable.PSEdition -ne 'Core') { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } } function Wait-AppVisibleToMicrosoft { param( [Parameter(Mandatory = $true)][string]$ClientId, [Parameter(Mandatory = $true)][string]$TenantId ) for ($attempt = 1; $attempt -le $script:VisibilityChecks; $attempt++) { try { $answer = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode" ` -Body @{ client_id = $ClientId; scope = 'openid' } -TimeoutSec 15 -ErrorAction Stop if ($null -ne $answer -and -not [string]::IsNullOrEmpty($answer.device_code)) { return $true } } catch { Write-Verbose "Microsoft doesn't see app $ClientId yet: $(Get-HttpErrorDetail -ErrorRecord $_)" } if ($attempt -lt $script:VisibilityChecks) { Write-Host " Microsoft doesn't see the new app yet ($attempt/$($script:VisibilityChecks)), waiting..." -ForegroundColor Gray Start-Sleep -Seconds $script:VisibilityDelaySeconds } } return $false } function Send-EntraSetup { param( [Parameter(Mandatory = $true)][string]$SetupCode, [Parameter(Mandatory = $true)][string]$ClientId, [Parameter(Mandatory = $true)][string]$TenantId ) $body = ConvertTo-Json -InputObject @{ code = $SetupCode; client_id = $ClientId; tenant_id = $TenantId } -Compress $params = @{ Method = 'Post' Uri = "$(Get-SettingsUrl)/entra-setup" Body = $body ContentType = 'application/json' TimeoutSec = 60 ErrorAction = 'Stop' } try { return Invoke-RestMethod @params } catch { throw (Get-HttpErrorDetail -ErrorRecord $_) } } function Write-Step { param([string]$Text) Write-Host $Text -ForegroundColor Yellow } function Write-Done { param([string]$Text) Write-Host " $Text [OK]" -ForegroundColor Green } function New-MantraOutlookAddinApp { <# .SYNOPSIS Create the Entra app of the LibraCyber Outlook add-in in your tenant .DESCRIPTION Creates (or updates, when it already exists) a single-tenant app registration that the LibraCyber Outlook add-in uses to sign your users in: 1. Connects to Microsoft Graph as you (interactive login) 2. Creates the app, or finds the one created by a previous run 3. Exposes the add-in API (access_as_user) and pre-authorizes Office 4. Creates the app's service principal 5. Grants admin consent for openid, profile and email Mantra receives no permission in your tenant: the app has no secret and no application permission. Send the client ID and tenant ID printed at the end to Mantra. .PARAMETER TenantId Your tenant ID or primary domain. Default: the tenant of the account you sign in with. .PARAMETER SetupCode The one-time code Mantra gave you (valid 30 minutes). With it, the command sends the client ID and tenant ID to Mantra itself; without it, it prints them for you to send. .EXAMPLE New-MantraOutlookAddinApp .EXAMPLE New-MantraOutlookAddinApp -TenantId "contoso.onmicrosoft.com" .EXAMPLE New-MantraOutlookAddinApp -SetupCode "<code from Mantra>" .OUTPUTS PSCustomObject with ClientId, TenantId and SentToMantra. .NOTES Requirements: - Cloud Application Administrator, Application Administrator or Global Administrator role - Microsoft.Graph.Authentication module - PowerShell 5.1 or later #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [ValidatePattern('^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}|[A-Za-z0-9.-]+)$')] [string]$TenantId, [Parameter(Mandatory = $false)] [ValidatePattern('^[A-Za-z0-9_-]{32}$')] [string]$SetupCode ) $ErrorActionPreference = 'Stop' $addinHost = Get-AddinHost Write-Host "" Write-Host "============================================" -ForegroundColor Cyan Write-Host " LibraCyber Outlook add-in - Entra app " -ForegroundColor Cyan Write-Host "============================================" -ForegroundColor Cyan Write-Host "" Write-Host "Add-in host : $addinHost" -ForegroundColor Gray Write-Host "" Write-Host "Microsoft will ask you to consent to these permissions for" -ForegroundColor White Write-Host "'Microsoft Graph Command Line Tools' (a Microsoft app, not Mantra):" -ForegroundColor White Write-Host " - Application.ReadWrite.All: create the add-in app" -ForegroundColor Gray Write-Host " - DelegatedPermissionGrant.ReadWrite.All: consent to openid, profile, email for it" -ForegroundColor Gray Write-Host "They act as you, never beyond your own role. The sign-in lasts only for" -ForegroundColor White Write-Host "this command, but the consent stays until you remove it in Entra:" -ForegroundColor White Write-Host " Enterprise applications > Microsoft Graph Command Line Tools > Permissions" -ForegroundColor Gray Write-Host "Do NOT tick 'Consent on behalf of your organization'." -ForegroundColor White Write-Host "" try { Write-Step "[1/5] Connecting to Microsoft Graph..." Write-Host " (Login window will open)" -ForegroundColor Gray $connect = @{ Scopes = $script:AdminScopes; ContextScope = 'Process'; NoWelcome = $true; ErrorAction = 'Stop' } if ($TenantId) { $connect['TenantId'] = $TenantId } Connect-MgGraph @connect $connectedTenant = (Get-MgContext).TenantId Write-Done "Connected to tenant $connectedTenant" Write-Step "[2/5] Creating the app..." $graphSp = Get-ServicePrincipalByAppId -AppId $script:GraphAppId if ($null -eq $graphSp) { throw "Microsoft Graph has no service principal in this tenant" } $graphScopeIds = Get-GraphScopeIds -GraphServicePrincipal $graphSp $app = Find-AddinApp -AddinHost $addinHost if ($null -eq $app) { $app = Invoke-Graph -Method POST -Path '/applications' -Body (New-AddinAppCreation -AddinHost $addinHost) Write-Done "Created (client ID $($app.appId))" } else { Write-Done "Already exists (client ID $($app.appId)), updating it" } Write-Step "[3/5] Exposing the add-in API..." $scopeExists = Test-AccessAsUserScope -App $app $scopeId = Get-AccessAsUserScopeId -App $app $apiPatch = New-AddinApiPatch -AddinHost $addinHost -AppId $app.appId -ScopeId $scopeId -GraphScopeIds $graphScopeIds $preAuthorization = New-AddinPreAuthorizationPatch -ScopeId $scopeId if ($scopeExists) { $apiPatch.api = $preAuthorization.api } Invoke-WithRetry -Label 'Exposing the API' -Action { Invoke-Graph -Method PATCH -Path "/applications/$($app.id)" -Body $apiPatch } | Out-Null if (-not $scopeExists) { Invoke-WithRetry -Label 'Pre-authorizing Office' -Action { Invoke-Graph -Method PATCH -Path "/applications/$($app.id)" -Body $preAuthorization } | Out-Null } Write-Done "api://$addinHost/$($app.appId), Office pre-authorized" Write-Step "[4/5] Creating the service principal..." $sp = Get-ServicePrincipalByAppId -AppId $app.appId if ($null -eq $sp) { $sp = Invoke-WithRetry -Label 'Creating the service principal' -Action { Invoke-Graph -Method POST -Path '/servicePrincipals' -Body @{ appId = $app.appId } } Write-Done "Created" } else { Write-Done "Already exists" } Write-Step "[5/5] Granting admin consent (openid, profile, email)..." $consent = Invoke-WithRetry -Label 'Granting admin consent' -Action { Set-AdminConsent -ClientSpId $sp.id -GraphSpId $graphSp.id } Write-Done $consent $sent = $false if ($SetupCode) { Write-Step "[+] Sending the app to Mantra..." Enable-Tls12 if (-not (Wait-AppVisibleToMicrosoft -ClientId $app.appId -TenantId $connectedTenant)) { Write-Host " Microsoft still doesn't see the app: sending it anyway" -ForegroundColor Gray } try { $done = Send-EntraSetup -SetupCode $SetupCode -ClientId $app.appId -TenantId $connectedTenant $sent = $true Write-Done "Saved by Mantra (settings revision $($done.revision))" } catch { Write-Host " Not saved at Mantra: $($_.Exception.Message)" -ForegroundColor Red } } Write-Host "" Write-Host "============================================" -ForegroundColor Green if ($sent) { Write-Host " APP READY AND SAVED BY MANTRA" -ForegroundColor Green } else { Write-Host " APP READY - send these two values to Mantra" -ForegroundColor Green } Write-Host "============================================" -ForegroundColor Green Write-Host "" Write-Host " Client ID : $($app.appId)" -ForegroundColor White Write-Host " Tenant ID : $connectedTenant" -ForegroundColor White Write-Host "" if ($sent) { Write-Host "Next: download the new manifest from Mantra and upload it in" -ForegroundColor Cyan Write-Host "Microsoft 365 admin center > Integrated apps." -ForegroundColor Cyan Write-Host "" } return [pscustomobject]@{ ClientId = $app.appId; TenantId = $connectedTenant; SentToMantra = $sent } } catch { Write-Host "" Write-Host "[X] Setup failed" -ForegroundColor Red Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red Write-Host "" Write-Host "Troubleshooting:" -ForegroundColor Yellow Write-Host " - Sign in with a Cloud Application Administrator, Application Administrator or Global Administrator" -ForegroundColor Gray Write-Host " - Run the command again: it updates the app it already created" -ForegroundColor Gray Write-Host "" Write-Host "Contact support: support@mantra.ms" -ForegroundColor Cyan Write-Host "" throw } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } } Export-ModuleMember -Function New-MantraOutlookAddinApp |