Skip to content

Commit

Permalink
feat: CIPP-API Integration v2
Browse files Browse the repository at this point in the history
New authentication handling for CIPP-API integration
IP whitelisting
Role selection
Multiple app support
Update Test-CippAccess to handle aad auth from function app
New IAM permission required for function app to use identity for function app changes - contributor role required on itself
  • Loading branch information
JohnDuprey committed Feb 5, 2025
1 parent be0cd79 commit 6465f7d
Show file tree
Hide file tree
Showing 10 changed files with 565 additions and 119 deletions.
23 changes: 23 additions & 0 deletions Modules/CIPPCore/Public/Authentication/Get-CippApiAuth.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
function Get-CippApiAuth {
Param(
[string]$RGName,
[string]$FunctionAppName
)

# Get subscription id
$SubscriptionId = (Get-AzContext).Subscription.Id

# Get auth settings
$AuthSettings = Invoke-AzRestMethod -Uri "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$RGName/providers/Microsoft.Web/sites/$($FunctionAppName)/config/authsettingsV2/list?api-version=2020-06-01" -ErrorAction Stop | Select-Object -ExpandProperty Content | ConvertFrom-Json

if ($AuthSettings.properties) {
[PSCustomObject]@{
ApiUrl = "https://$($FunctionAppName).azurewebsites.net"
TenantID = $AuthSettings.properties.identityProviders.azureActiveDirectory.registration.openIdIssuer -replace 'https://sts.windows.net/', '' -replace '/v2.0', ''
ClientIDs = $AuthSettings.properties.identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications
Enabled = $AuthSettings.properties.identityProviders.azureActiveDirectory.enabled
}
} else {
throw 'No auth settings found'
}
}
43 changes: 43 additions & 0 deletions Modules/CIPPCore/Public/Authentication/Get-CippApiClient.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
function Get-CippApiClient {
<#
.SYNOPSIS
Get the API client details
.DESCRIPTION
This function retrieves the API client details
.PARAMETER AppId
The AppId of the API client
.EXAMPLE
Get-CippApiClient -AppId 'cipp-api'
#>
[CmdletBinding()]
param (
$AppId
)

$Table = Get-CIPPTable -TableName 'ApiClients'
if ($AppId) {
$Table.Filter = "RowKey eq '$AppId'"
}
$Apps = Get-CIPPAzDataTableEntity @Table
$Apps = foreach ($Client in $Apps) {
$Client = $Client | Select-Object -Property @{Name = 'ClientId'; Expression = { $_.RowKey } }, AppName, Role, IPRange, Enabled

if (!$Client.Role) {
$Client.Role = $null
}

if ($Client.IPRange) {
try {
$IPRange = @($Client.IPRange | ConvertFrom-Json -ErrorAction Stop)
if (($IPRange | Measure-Object).Count -eq 0) { @('Any') }
$Client.IPRange = $IPRange
} catch {
$Client.IPRange = @('Any')
}
} else {
$Client.IPRange = @('Any')
}
$Client
}
return $Apps
}
147 changes: 147 additions & 0 deletions Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
function New-CIPPAPIConfig {

[CmdletBinding(SupportsShouldProcess)]
param (
$APIName = 'CIPP API Config',
$ExecutingUser,
[switch]$ResetSecret,
[string]$AppName,
[string]$AppId
)

try {
if ($AppId) {
$APIApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appid='$($AppId)')" -NoAuthCheck $true
} else {
$CreateBody = @{
api = @{
oauth2PermissionScopes = @(
@{
adminConsentDescription = 'Allow the application to access CIPP-API on behalf of the signed-in user.'
adminConsentDisplayName = 'Access CIPP-API'
id = 'ba7ffeff-96ea-4ac4-9822-1bcfee9adaa4'
isEnabled = $true
type = 'User'
userConsentDescription = 'Allow the application to access CIPP-API on your behalf.'
userConsentDisplayName = 'Access CIPP-API'
value = 'user_impersonation'
}
)
}
displayName = $AppName
requiredResourceAccess = @(
@{
resourceAccess = @(
@{
id = 'e1fe6dd8-ba31-4d61-89e7-88639da4683d'
type = 'Scope'
}
)
resourceAppId = '00000003-0000-0000-c000-000000000000'
}
)
signInAudience = 'AzureADMyOrg'
web = @{
homePageUrl = 'https://cipp.app'
implicitGrantSettings = @{
enableAccessTokenIssuance = $false
enableIdTokenIssuance = $true
}
redirectUris = @("https://$($ENV:Website_hostname)/.auth/login/aad/callback")
}
} | ConvertTo-Json -Depth 10 -Compress

if ($PSCmdlet.ShouldProcess($AppName, 'Create API App')) {
Write-Information 'Creating app'
$APIApp = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/applications' -NoAuthCheck $true -type POST -body $CreateBody

$Requests = @(
@{
id = 'addPassword'
method = 'POST'
url = "/applications/$($APIApp.id)/addPassword"
body = @{
passwordCredential = @{
displayName = 'Generated by API Setup'
}
}
},
@{
id = 'apiIdentifier'
method = 'PATCH'
url = "/applications/$($APIApp.id)"
body = @{
identifierUris = @("api://$($APIApp.appId)")
}
},
@{
id = 'tagServicePrincipal'
method = 'POST'
url = '/serviceprincipals'
body = @{
accountEnabled = $true
appId = $APIApp.appId
displayName = 'CIPP-API'
tags = @('WindowsAzureActiveDirectoryIntegratedApp', 'AppServiceIntegratedApp')
}
}
)

$BatchResponse = New-GraphBulkRequest -tenantid $env:TenantID -NoAuthCheck $true -asapp $true -Requests $Requests
$APIPassword = $BatchResponse | Where-Object { $_.id -eq 'addPassword' } | Select-Object -ExpandProperty body
Write-LogMessage -user $ExecutingUser -API $APINAME -tenant 'None '-message "Created CIPP API App for $($APIApp.displayName)." -Sev 'info'
}
}
if ($ResetSecret.IsPresent -and $APIApp) {
if ($PSCmdlet.ShouldProcess($APIApp.displayName, 'Reset API Secret')) {
Write-Information 'Removing all old passwords'
$Requests = @(
@{
id = 'removeOldPasswords'
method = 'PATCH'
url = "applications/$($APIApp.id)/"
headers = @{
'Content-Type' = 'application/json'
}
body = @{
passwordCredentials = @()
}
},
@{
id = 'addNewPassword'
method = 'POST'
url = "applications/$($APIApp.id)/addPassword"
headers = @{
'Content-Type' = 'application/json'
}
body = @{
passwordCredential = @{
displayName = 'Generated by API Setup'
}
}
dependsOn = @('removeOldPasswords')
}
)
$BatchResponse = New-GraphBulkRequest -tenantid $env:TenantID -NoAuthCheck $true -asapp $true -Requests $Requests
$APIPassword = $BatchResponse | Where-Object { $_.id -eq 'addNewPassword' } | Select-Object -ExpandProperty body
Write-LogMessage -user $ExecutingUser -API $APINAME -tenant 'None '-message "Reset CIPP API Password for $($APIApp.displayName)." -Sev 'info'
}
}

return @{
AppName = $APIApp.displayName
ApplicationID = $APIApp.AppId
ApplicationSecret = $APIPassword.secretText
Results = $Results
}

} catch {
$ErrorMessage = Get-CippException -Exception $_
Write-Information ($ErrorMessage | ConvertTo-Json -Depth 10)
Write-LogMessage -user $ExecutingUser -API $APINAME -tenant 'None' -message "Failed to setup CIPP-API Access: $($ErrorMessage.NormalizedError) Linenumber: $($_.InvocationInfo.ScriptLineNumber)" -Sev 'Error' -LogData $ErrorMessage
return @{
Results = "Failed to setup CIPP-API Access: $($ErrorMessage.NormalizedError)"
}

}
}
54 changes: 54 additions & 0 deletions Modules/CIPPCore/Public/Authentication/Set-CippApiAuth.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
function Set-CippApiAuth {
[CmdletBinding(SupportsShouldProcess)]
Param(
[string]$RGName,
[string]$FunctionAppName,
[string]$TenantId,
[string[]]$ClientIds
)

# Get subscription id
$SubscriptionId = (Get-AzContext).Subscription.Id

# Get auth settings
$AuthSettings = Invoke-AzRestMethod -Uri "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$RGName/providers/Microsoft.Web/sites/$($FunctionAppName)/config/authsettingsV2/list?api-version=2020-06-01" | Select-Object -ExpandProperty Content | ConvertFrom-Json

# Set allowed audiences
$AllowedAudiences = foreach ($ClientId in $ClientIds) {
"api://$ClientId"
}

# Set auth settings
$AuthSettings.properties.identityProviders.azureActiveDirectory = @{
registration = @{
clientId = $ClientIds[0] ?? $ClientIds
openIdIssuer = "https://sts.windows.net/$TenantID/v2.0"
}
validation = @{
allowedAudiences = @($AllowedAudiences)
defaultAuthorizationPolicy = @{
allowedApplications = @($ClientIds)
}
}
}
$AuthSettings.properties.globalValidation = @{
unauthenticatedClientAction = 'Return401'
}
$AuthSettings.properties.login = @{
tokenStore = @{
enabled = $true
tokenRefreshExtensionHours = 72
}
}

Write-Information ($AuthSettings | ConvertTo-Json -Depth 10)

if ($PSCmdlet.ShouldProcess('Update auth settings')) {
# Update auth settings
Invoke-AzRestMethod -Uri "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$RGName/providers/Microsoft.Web/sites/$($FunctionAppName)/config/authsettingsV2?api-version=2020-06-01" -Method PUT -Payload ($AuthSettings | ConvertTo-Json -Depth 10)
}

if ($PSCmdlet.ShouldProcess('Update allowed tenants')) {
Update-AzFunctionAppSetting -Name $FunctionAppName -ResourceGroupName $RGName -AppSetting @{ 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS' = $TenantId }
}
}
37 changes: 25 additions & 12 deletions Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,40 @@ function Test-CIPPAccess {
# Check help for role
$APIRole = $Help.Role

if (!$Request.Headers.'x-ms-client-principal' -or ($Request.Headers.'x-ms-client-principal-id' -and $Request.Headers.'x-ms-client-principal-idp' -eq 'aad')) {
if ($Request.Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Request.Headers.'x-ms-client-principal-name' -match '^[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}$') {
# Direct API Access
$ForwardedFor = $Request.Headers.'x-forwarded-for' -split ',' | Select-Object -First 1
$IPRegex = '^(?<IP>(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$'
$IPAddress = $Request.Headers.'x-forwarded-for' -replace $IPRegex, '$1' -replace '[\[\]]', ''
Write-Information "API Access: AppId=$($Request.Headers.'x-ms-client-principal-id') IP=$IPAddress"
$IPAddress = $ForwardedFor -replace $IPRegex, '$1' -replace '[\[\]]', ''

# TODO: Implement API Client support, create Get-CippApiClient function
<#$Client = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-id'
$Client = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-name'
if ($Client) {
if ($Client.AllowedIPs -contains $IPAddress -or $Client.AllowedIPs -contains 'All')) {
if ($Client.CustomRoles) {
$CustomRoles = @($Client.CustomRoles)
Write-Information "API Access: AppName=$($Client.AppName), AppId=$($Request.Headers.'x-ms-client-principal-name'), IP=$IPAddress"
$IPMatched = $false
if ($Client.IPRange -notcontains 'Any') {
foreach ($Range in $Client.IPRange) {
if ($IPaddress -eq $Range -or (Test-IpInRange -IPAddress $IPAddress -Range $Range)) {
$IPMatched = $true
break
}
}
} else {
$IPMatched = $true
}

if ($IPMatched) {
if ($Client.Role) {
$CustomRoles = @($Client.Role)
} else {
$CustomRoles = @('CIPP-API')
$CustomRoles = @('cipp-api')
}
} else {
throw 'Access to this CIPP API endpoint is not allowed, the API Client does not have the required permission'
}
} else { #>
$CustomRoles = @('cipp-api')
# }
} else {
$CustomRoles = @('cipp-api')
Write-Information "API Access: AppId=$($Request.Headers.'x-ms-client-principal-name'), IP=$IPAddress"
}
} else {
$DefaultRoles = @('admin', 'editor', 'readonly', 'anonymous', 'authenticated')
$User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json
Expand Down
53 changes: 53 additions & 0 deletions Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
function Test-IpInRange {
<#
.SYNOPSIS
Test if an IP address is in a CIDR range
.DESCRIPTION
This function tests if an IP address is in a CIDR range
.PARAMETER IPAddress
The IP address to test
.PARAMETER Range
The CIDR range to test
.EXAMPLE
Test-IpInRange -IPAddress "1.1.1.1" -Range "1.1.1.1/24"
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$IPAddress,
[Parameter(Mandatory = $true)]
[string]$Range
)

function ConvertIpToBigInteger {
param([System.Net.IPAddress]$ip)
return [System.Numerics.BigInteger]::Parse(
[BitConverter]::ToString($ip.GetAddressBytes()).Replace('-', ''),
[System.Globalization.NumberStyles]::HexNumber
)
}

try {
$IP = [System.Net.IPAddress]::Parse($IPAddress)
$rangeParts = $Range -split '/'
$networkAddr = [System.Net.IPAddress]::Parse($rangeParts[0])
$prefix = [int]$rangeParts[1]

if ($networkAddr.AddressFamily -ne $IP.AddressFamily) {
return $false
}

$ipBig = ConvertIpToBigInteger $IP
$netBig = ConvertIpToBigInteger $networkAddr
$maxBits = if ($networkAddr.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 }
$shift = $maxBits - $prefix
$mask = [System.Numerics.BigInteger]::Pow(2, $shift) - [System.Numerics.BigInteger]::One
$invertedMask = [System.Numerics.BigInteger]::MinusOne -bxor $mask
$ipMasked = $ipBig -band $invertedMask
$netMasked = $netBig -band $invertedMask

return $ipMasked -eq $netMasked
} catch {
return $false
}
}
Loading

0 comments on commit 6465f7d

Please sign in to comment.