Create-CopilotWebhookApp.ps1

<#
.SYNOPSIS
Creates a Microsoft Entra App Registration and adds a Federated Identity Credential (FIC) for Copilot Studio's security webhook.
 
The script uses MSAL.PS for authentication and is compatible with PowerShell 5.1+.
 
.PARAMETER TenantId
The Azure AD tenant ID (GUID format required).
 
.PARAMETER Endpoint
Webhook endpoint URL. Must be a valid HTTPS URL that exactly matches what Copilot Studio uses.
 
.PARAMETER DisplayName
Display name for the new app registration. Must be unique within the tenant.
 
.PARAMETER FICName
Name of the Federated Identity Credential to create.
 
.PARAMETER DryRun
Optional. When specified, shows what would be created without making actual changes.
 
.EXAMPLE
.\Create-CopilotWebhookApp.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -Endpoint "https://your.webhook/endpoint" -DisplayName "My Copilot App" -FICName "WebhookFIC"
 
.EXAMPLE
.\Create-CopilotWebhookApp.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -Endpoint "https://your.webhook/endpoint" -DisplayName "My Copilot App" -FICName "WebhookFIC" -DryRun
 
.NOTES
Requires PowerShell 5.1 or later and internet connectivity to access Microsoft Graph API.
The script will automatically install MSAL.PS module if not present.
The user must have sufficient permissions to create app registrations in the specified tenant.
#>


<#PSScriptInfo
.VERSION 1.0.0
.GUID 8a028c75-549a-4194-aaa7-56c5d5b1f30c
.AUTHOR Avital Livshits
.COMPANYNAME Microsoft
.COPYRIGHT (c) 2025 Microsoft. All rights reserved.
.TAGS Copilot Studio, Security Webhooks, Security Extensibility
.LICENSEURI https://opensource.org/licenses/MIT
.EXTERNALMODULEDEPENDENCIES MSAL.PS
.DESCRIPTION This script creates a Microsoft Entra App Registration and adds a Federated Identity Credential (FIC) for Copilot Studio's security webhook.
#>


[CmdletBinding()]
param (
    [Parameter(Mandatory = $true, HelpMessage = "Enter the Azure AD tenant ID (GUID)")]
    [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')]
    [string]$TenantId,
    
    [Parameter(Mandatory = $true, HelpMessage = "Enter the webhook endpoint URL (must be HTTPS)")]
    [ValidatePattern('^https://.*')]
    [string]$Endpoint,
    
    [Parameter(Mandatory = $true, HelpMessage = "Enter a display name for the app registration")]
    [ValidateLength(1, 120)]
    [string]$DisplayName,
    
    [Parameter(Mandatory = $true, HelpMessage = "Enter a name for the Federated Identity Credential")]
    [ValidateLength(1, 120)]
    [string]$FICName,
    
    [Parameter(Mandatory = $false, HelpMessage = "Preview what would be created without making changes")]
    [switch]$DryRun
)

# Global variables
$script:AccessToken = $null
$script:GraphBaseUrl = "https://graph.microsoft.com/v1.0"

#region Helper Functions

function Write-Step {
    param([string]$Message, [string]$Color = "Cyan")
    Write-Host "`n$Message" -ForegroundColor $Color
}

function Write-Success {
    param([string]$Message)
    Write-Host "✔ $Message" -ForegroundColor Green
}

function Write-Error {
    param([string]$Message)
    Write-Host "✗ $Message" -ForegroundColor Red
}

function Write-Warning {
    param([string]$Message)
    Write-Host "⚠ $Message" -ForegroundColor Yellow
}

function Show-MSALInstallationInstructions {
    <#
    .SYNOPSIS
    Displays manual installation instructions for MSAL.PS module.
    #>

    Write-Host "`nManual Installation Required:" -ForegroundColor Yellow
    Write-Host " 1. Open a new PowerShell window" -ForegroundColor White
    Write-Host " 2. Run the following command:" -ForegroundColor White
    Write-Host " Install-Module -Name MSAL.PS -Scope CurrentUser -Force" -ForegroundColor Cyan
    Write-Host " 3. If prompted to trust the repository, type 'Y' and press Enter" -ForegroundColor White
    Write-Host " 4. After installation completes, run this script again" -ForegroundColor White
    Write-Host "`nIf you encounter issues:" -ForegroundColor Yellow
    Write-Host " • Ensure you have internet connectivity" -ForegroundColor Gray
    Write-Host " • Check if your organization uses a proxy server" -ForegroundColor Gray
    Write-Host " • Contact your IT administrator if access is blocked" -ForegroundColor Gray
}

function Test-MSALModule {
    <#
    .SYNOPSIS
    Checks if MSAL.PS module is installed and installs it if needed.
    #>

    try {
        Write-Step "Checking MSAL.PS module..."
        
        # Check if MSAL.PS is installed
        $msalModule = Get-Module -ListAvailable -Name MSAL.PS
        
        if (-not $msalModule) {
            Write-Warning "MSAL.PS module is not installed"
            
            # Check if we can install modules (not in restricted execution policy)
            $executionPolicy = Get-ExecutionPolicy
            if ($executionPolicy -eq "Restricted") {
                Write-Warning "PowerShell execution policy is Restricted - cannot auto-install modules"
                Show-MSALInstallationInstructions
                throw "MSAL.PS module is required but cannot be auto-installed due to execution policy."
            }
            
            # Ask user for permission to install
            Write-Host " The MSAL.PS module is required for authentication." -ForegroundColor Yellow
            Write-Host " This module will be installed for the current user only." -ForegroundColor Yellow
            
            if ($DryRun) {
                Write-Host " [DRY RUN] Would install MSAL.PS module" -ForegroundColor Gray
                return $true
            }
            
            $response = Read-Host " Install MSAL.PS module now? [Y/N]"
            if ($response -notmatch '^[Yy]') {
                Show-MSALInstallationInstructions
                throw "MSAL.PS module is required to continue."
            }
            
            Write-Host " Installing MSAL.PS module..." -ForegroundColor Gray
            try {
                Install-Module -Name MSAL.PS -Scope CurrentUser -Force -AllowClobber
                Write-Success "MSAL.PS module installed successfully"
            }
            catch {
                Write-Error "Failed to install MSAL.PS module automatically: $($_.Exception.Message)"
                Show-MSALInstallationInstructions
                throw "Cannot continue without MSAL.PS module. Please install it manually and try again."
            }
        }
        else {
            Write-Success "MSAL.PS module is available"
        }
        
        # Import the module
        if (-not $DryRun) {
            Import-Module MSAL.PS -Force
            Write-Host " MSAL.PS module loaded successfully" -ForegroundColor Gray
        }
        
        return $true
    }
    catch {
        throw "MSAL.PS module setup failed: $($_.Exception.Message)"
    }
}

function Get-AccessToken {
    <#
    .SYNOPSIS
    Authenticates with Microsoft Graph using MSAL and returns an access token.
    #>

    param([string]$TenantId)
    
    try {
        Write-Step "Authenticating with Microsoft Graph..."
        
        $clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # Microsoft Graph Command Line Tools
        $scopes = @("https://graph.microsoft.com/Application.ReadWrite.All", "https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All")
        
        # Use MSAL to get token with device code flow
        Write-Host " Starting device code authentication..." -ForegroundColor Gray
        Write-Host " You will need to complete authentication in your browser." -ForegroundColor Yellow
        
        $tokenResult = Get-MsalToken -ClientId $clientId -TenantId $TenantId -Scopes $scopes -DeviceCode
        
        if ($tokenResult -and $tokenResult.AccessToken) {
            Write-Success "Authenticated successfully using MSAL"
            return $tokenResult.AccessToken
        }
        else {
            throw "Failed to obtain access token"
        }
    }
    catch {
        throw "Failed to authenticate: $($_.Exception.Message)"
    }
}

function Invoke-GraphRequest {
    <#
    .SYNOPSIS
    Makes a REST request to Microsoft Graph with proper error handling.
    #>

    param(
        [string]$Uri,
        [string]$Method = "GET",
        [hashtable]$Body = $null,
        [string]$ContentType = "application/json"
    )
    
    if (-not $script:AccessToken) {
        throw "No access token available. Please authenticate first."
    }
    
    $headers = @{
        Authorization = "Bearer $script:AccessToken"
        "Content-Type" = $ContentType
    }
    
    try {
        $params = @{
            Uri = $Uri
            Method = $Method
            Headers = $headers
        }
        
        if ($Body -and $Method -ne "GET") {
            if ($ContentType -eq "application/json") {
                $params.Body = ($Body | ConvertTo-Json -Depth 10)
            } else {
                $params.Body = $Body
            }
        }
        
        return Invoke-RestMethod @params
    }
    catch {
        $errorDetails = "Unknown error"
        if ($_.Exception.Response) {
            try {
                $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
                $responseBody = $reader.ReadToEnd()
                $errorObj = $responseBody | ConvertFrom-Json
                if ($errorObj.error) {
                    $errorDetails = "$($errorObj.error.code): $($errorObj.error.message)"
                }
            }
            catch {
                $errorDetails = "HTTP $($_.Exception.Response.StatusCode)"
            }
        }
        throw "Graph API request failed: $errorDetails"
    }
}

function Test-ApplicationExists {
    <#
    .SYNOPSIS
    Checks if an application with the given display name already exists and returns app details if found.
    #>

    param([string]$DisplayName)
    
    $encodedName = [System.Web.HttpUtility]::UrlEncode($DisplayName)
    $uri = "$script:GraphBaseUrl/applications?`$filter=displayName eq '$encodedName'"
    
    $response = Invoke-GraphRequest -Uri $uri -Method GET
    if ($response.value -and $response.value.Count -gt 0) {
        return $response.value[0]  # Return the first matching app
    }
    return $null
}

function Test-FederatedIdentityCredentialExists {
    <#
    .SYNOPSIS
    Checks if a Federated Identity Credential with the given name already exists on the app.
    #>

    param(
        [string]$ApplicationObjectId,
        [string]$FICName
    )
    
    try {
        $uri = "$script:GraphBaseUrl/applications/$ApplicationObjectId/federatedIdentityCredentials"
        $response = Invoke-GraphRequest -Uri $uri -Method GET
        
        foreach ($fic in $response.value) {
            if ($fic.name -eq $FICName) {
                return $true
            }
        }
        return $false
    }
    catch {
        # If we can't fetch FICs, assume it doesn't exist and let the creation attempt handle any errors
        return $false
    }
}

function New-ApplicationRegistration {
    <#
    .SYNOPSIS
    Creates a new application registration.
    #>

    param([string]$DisplayName)
    
    $appBody = @{
        displayName = $DisplayName
        signInAudience = "AzureADMyOrg"
    }
    
    $uri = "$script:GraphBaseUrl/applications"
    return Invoke-GraphRequest -Uri $uri -Method POST -Body $appBody
}

function New-ServicePrincipalForApp {
    <#
    .SYNOPSIS
    Creates a service principal for the given application.
    #>

    param([string]$AppId)
    
    $spBody = @{
        appId = $AppId
    }
    
    $uri = "$script:GraphBaseUrl/servicePrincipals"
    return Invoke-GraphRequest -Uri $uri -Method POST -Body $spBody
}

function New-FederatedIdentityCredential {
    <#
    .SYNOPSIS
    Creates a Federated Identity Credential for the application.
    #>

    param(
        [string]$ApplicationObjectId,
        [string]$FICName,
        [string]$TenantId,
        [string]$Endpoint
    )
    
    # Build the subject claim as required by Copilot Studio
    $issuer = "https://login.microsoftonline.com/$TenantId/v2.0"
    $audience = "api://AzureADTokenExchange"
    
    # Encode tenant ID and endpoint for the subject
    $encodedTenantId = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($TenantId)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
    $encodedEndpoint = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Endpoint)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
    $subject = "/eid1/c/pub/t/$encodedTenantId/a/m1WPnYRZpEaQKq1Cceg--g/$encodedEndpoint"
    
    $ficBody = @{
        name = $FICName
        issuer = $issuer
        subject = $subject
        description = "Federated Identity Credential for Copilot Studio webhook authentication"
        audiences = @($audience)
    }
    
    $uri = "$script:GraphBaseUrl/applications/$ApplicationObjectId/federatedIdentityCredentials"
    return Invoke-GraphRequest -Uri $uri -Method POST -Body $ficBody
}

#endregion

#region Main Logic

function Start-AppCreation {
    param(
        [string]$TenantId,
        [string]$Endpoint,
        [string]$DisplayName,
        [string]$FICName,
        [bool]$DryRun
    )
    
    try {
        Write-Host "========================================" -ForegroundColor Cyan
        Write-Host " Copilot Webhook App Registration Tool" -ForegroundColor Cyan
        Write-Host "========================================" -ForegroundColor Cyan
        
        if ($DryRun) {
            Write-Warning "DRY RUN MODE - No changes will be made"
        }
        
        Write-Host "`nConfiguration:" -ForegroundColor White
        Write-Host " Tenant ID: $TenantId" -ForegroundColor Gray
        Write-Host " Endpoint: $Endpoint" -ForegroundColor Gray
        Write-Host " App Name: $DisplayName" -ForegroundColor Gray
        Write-Host " FIC Name: $FICName" -ForegroundColor Gray
        
        # Step 1: Ensure MSAL.PS is available
        Test-MSALModule
        
        # Step 2: Authenticate
        if (-not $DryRun) {
            $script:AccessToken = Get-AccessToken -TenantId $TenantId
        } else {
            Write-Step "Would authenticate with Microsoft Graph using MSAL"
        }
        
        # Step 3: Check if app already exists
        Write-Step "Checking if application already exists..."
        $app = $null
        $sp = $null
        $useExistingApp = $false
        
        if (-not $DryRun) {
            $existingApp = Test-ApplicationExists -DisplayName $DisplayName
            if ($existingApp) {
                Write-Warning "Application '$DisplayName' already exists"
                Write-Host " App ID: $($existingApp.appId)" -ForegroundColor Gray
                Write-Host " Object ID: $($existingApp.id)" -ForegroundColor Gray
                
                $response = Read-Host "`n Would you like to add a new FIC to the existing app? [Y/N]"
                if ($response -match '^[Yy]') {
                    $useExistingApp = $true
                    $app = $existingApp
                    Write-Success "Will use existing application"
                    
                    # Check if FIC name already exists
                    Write-Step "Checking if FIC name is unique..."
                    $ficExists = Test-FederatedIdentityCredentialExists -ApplicationObjectId $app.id -FICName $FICName
                    if ($ficExists) {
                        throw "Federated Identity Credential '$FICName' already exists on this application. Please choose a different FIC name."
                    }
                    Write-Success "FIC name is unique"
                }
                else {
                    throw "Operation cancelled. Please choose a different application name or remove the existing app."
                }
            }
            else {
                Write-Success "Application name is available"
            }
        } else {
            Write-Host " Would check if '$DisplayName' already exists" -ForegroundColor Gray
            Write-Host " Would prompt user if app exists" -ForegroundColor Gray
        }
        
        # Step 4: Create application (if needed)
        if (-not $useExistingApp) {
            Write-Step "Creating application registration..."
            if (-not $DryRun) {
                $app = New-ApplicationRegistration -DisplayName $DisplayName
                Write-Success "Application created successfully"
                Write-Host " App ID: $($app.appId)" -ForegroundColor Gray
                Write-Host " Object ID: $($app.id)" -ForegroundColor Gray
            } else {
                Write-Host " Would create application '$DisplayName'" -ForegroundColor Gray
                $app = @{ appId = "00000000-0000-0000-0000-000000000000"; id = "mock-object-id" }
            }
            
            # Step 5: Create service principal
            Write-Step "Creating service principal..."
            if (-not $DryRun) {
                $sp = New-ServicePrincipalForApp -AppId $app.appId
                Write-Success "Service principal created successfully"
                Write-Host " Service Principal ID: $($sp.id)" -ForegroundColor Gray
            } else {
                Write-Host " Would create service principal for app" -ForegroundColor Gray
                $sp = @{ id = "mock-sp-id" }
            }
        }
        else {
            Write-Step "Using existing application - skipping service principal creation"
            # For existing apps, we assume the service principal already exists
            # We could optionally verify this, but it's not critical for FIC creation
            $sp = @{ id = "existing-sp-id" }
        }
        
        # Step 6: Create Federated Identity Credential
        Write-Step "Creating Federated Identity Credential..."
        if (-not $DryRun) {
            $fic = New-FederatedIdentityCredential -ApplicationObjectId $app.id -FICName $FICName -TenantId $TenantId -Endpoint $Endpoint
            Write-Success "Federated Identity Credential created successfully"
        } else {
            Write-Host " Would create FIC '$FICName' with:" -ForegroundColor Gray
            Write-Host " Issuer: https://login.microsoftonline.com/$TenantId/v2.0" -ForegroundColor Gray
            Write-Host " Audience: api://AzureADTokenExchange" -ForegroundColor Gray
            Write-Host " Subject: /eid1/c/pub/t/[encoded-tenant]/a/m1WPnYRZpEaQKq1Cceg--g/[encoded-endpoint]" -ForegroundColor Gray
        }
        
        # Step 7: Summary
        Write-Host "`n========================================" -ForegroundColor Green
        if ($DryRun) {
            Write-Host " DRY RUN COMPLETE" -ForegroundColor Green
        } else {
            if ($useExistingApp) {
                Write-Host " FIC ADDED TO EXISTING APP" -ForegroundColor Green
            } else {
                Write-Host " APPLICATION REGISTRATION COMPLETE" -ForegroundColor Green
            }
        }
        Write-Host "========================================" -ForegroundColor Green
        
        Write-Host "`nApplication Details:" -ForegroundColor White
        Write-Host " Display Name: $DisplayName" -ForegroundColor Gray
        Write-Host " App ID: $($app.appId)" -ForegroundColor Gray
        Write-Host " Object ID: $($app.id)" -ForegroundColor Gray
        Write-Host " Service Principal ID: $($sp.id)" -ForegroundColor Gray
        Write-Host " FIC Name: $FICName" -ForegroundColor Gray
        
        Write-Host "`n✔ Process completed successfully!" -ForegroundColor Green
    }
    catch {
        Write-Error "Failed to create application: $($_.Exception.Message)"
        Write-Host "`nTroubleshooting:" -ForegroundColor White
        Write-Host " • Ensure you have Application.ReadWrite.All permissions in the tenant" -ForegroundColor Yellow
        Write-Host " • Verify the tenant ID is correct" -ForegroundColor Yellow
        Write-Host " • Check that the application name is unique" -ForegroundColor Yellow
        Write-Host " • Ensure the endpoint URL is valid and uses HTTPS" -ForegroundColor Yellow
        Write-Host " • Check that the FIC name is unique within the application" -ForegroundColor Yellow
        throw
    }
}

#endregion

# Main execution
try {
    # Load System.Web for URL encoding (available in PowerShell 5.1)
    Add-Type -AssemblyName System.Web
    
    Start-AppCreation -TenantId $TenantId -Endpoint $Endpoint -DisplayName $DisplayName -FICName $FICName -DryRun $DryRun.IsPresent
}
catch {
    Write-Host "`n❌ Script execution failed!" -ForegroundColor Red
    exit 1
}