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