From d22ff2bfce2fec90e245876c94d71b819552de4f Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Tue, 19 May 2026 18:18:38 +0200 Subject: [PATCH 01/20] Implement Revoke-SCEPmanCertificate --- SCEPmanClient/Private/x509/constants.ps1 | 14 ++ .../Public/Revoke-SCEPmanCertificate.ps1 | 139 ++++++++++++++++++ SCEPmanClient/SCEPmanClient.psd1 | 3 +- 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 diff --git a/SCEPmanClient/Private/x509/constants.ps1 b/SCEPmanClient/Private/x509/constants.ps1 index b56223e..3999210 100644 --- a/SCEPmanClient/Private/x509/constants.ps1 +++ b/SCEPmanClient/Private/x509/constants.ps1 @@ -44,4 +44,18 @@ enum ValidityPeriod { Weeks Months Years +} + +enum RevocationReason { + # Good = -1 + Unspecified = 0 + KeyCompromise = 1 + CACompromise = 2 + AffiliationChanged = 3 + Superseded = 4 + CessationOfOperation = 5 + CertificateHold = 6 + RemoveFromCrl = 8 + PrivilegeWithdrawn = 9 + AACompromise = 10 } \ No newline at end of file diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 new file mode 100644 index 0000000..b11ce88 --- /dev/null +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -0,0 +1,139 @@ +<# +.SYNOPSIS + Revoke a certificate issued by SCEPman. + +.DESCRIPTION + This function revokes a certificate issued by SCEPman by calling the SCEPman revocation API. + +.PARAMETER Url + The URL of the SCEPman App Service. + +.PARAMETER SerialNumber + One or more serial numbers of the certificates to revoke. + +.PARAMETER RevocationReason + The reason for revoking the certificate. + +.PARAMETER Revoker + The identity of the person or entity revoking the certificate (e.g. admin@contoso.com). If not provided, the current Azure context account will be used. + +.PARAMETER ResourceUrl + The resource URL of the SCEPman service. If not provided, the function will try to find the Enterprise Application for the URL. + +.PARAMETER IgnoreExistingSession + Ignore existing Azure session. + +.PARAMETER DeviceCode + Use device code authentication. + +.PARAMETER Identity + Use the managed identity for authentication. + +.PARAMETER ClientId + The client ID for service principal authentication. + +.PARAMETER TenantId + The tenant ID for service principal authentication. + +.PARAMETER ClientSecret + The client secret for service principal authentication. + +.EXAMPLE + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D" -RevocationReason KeyCompromise -Revoker "admin@contoso.com" + +.EXAMPLE + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D","5E6F7A8B" -RevocationReason Superseded +#> + +Function Revoke-SCEPmanCertificate { + [CmdletBinding()] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUsernameAndPasswordParams", "", Justification="Service principal authentication requires username and password.")] + Param( + [Parameter(Mandatory, Position=0)] + [Alias('AppServiceUrl')] + [String]$Url, + + [Parameter(Mandatory, Position=1, ValueFromPipeline)] + [String[]]$SerialNumber, + + [Parameter(Mandatory)] + [RevocationReason]$RevocationReason, + + [String]$Revoker, + + [String]$ResourceUrl, + + [Switch]$IgnoreExistingSession, + [Switch]$DeviceCode, + [Switch]$Identity, + [String]$ClientId, + [String]$TenantId, + [String]$ClientSecret + ) + + Begin { + $ErrorActionPreference = 'Stop' + + Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null + + $Connect_Params = @{} + + If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } + If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } + If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } + If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } + If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } + If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } + + Connect-SCEPmanAzAccount @Connect_Params + + If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { + Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" + $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url + } + + $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + + If (-not $PSBoundParameters.ContainsKey('Revoker')) { + $Revoker = (Get-AzContext).Account.Id + Write-Verbose "$($MyInvocation.MyCommand): No revoker provided. Using current Azure context: $Revoker" + } + } + + Process { + $BaseUrl = $Url.TrimEnd('/') + + $Headers = @{ + 'Authorization' = "Bearer $AccessToken" + 'Content-Type' = 'application/json' + } + + foreach ($Serial in $SerialNumber) { + $RequestUrl = "$BaseUrl/api/manage/revoke/$Serial" + + $Body = @{ + revocationReason = [int]$RevocationReason + revoker = $Revoker + } | ConvertTo-Json + + Write-Verbose "$($MyInvocation.MyCommand): Sending revocation request to $RequestUrl" + + try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body + Write-Verbose "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." + $Response + } catch { + $StatusCode = $_.Exception.Response.StatusCode.value__ + Write-Error "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. Status code: $StatusCode. Error details: $($_ | Out-String)" + + switch ($StatusCode) { + 401 { throw "$($MyInvocation.MyCommand): Unauthorized. Authentication failed. $_" } + 400 { throw "$($MyInvocation.MyCommand): Bad request. Check the request body for errors. $_" } + 404 { throw "$($MyInvocation.MyCommand): Certificate not found. Verify the URL and that serial number '$Serial' exists. $_" } + 500 { throw "$($MyInvocation.MyCommand): Server error. The certificate may have already been revoked. $_" } + default { throw $_ } + } + } + } + } +} diff --git a/SCEPmanClient/SCEPmanClient.psd1 b/SCEPmanClient/SCEPmanClient.psd1 index 85695ef..496cc47 100644 --- a/SCEPmanClient/SCEPmanClient.psd1 +++ b/SCEPmanClient/SCEPmanClient.psd1 @@ -35,7 +35,8 @@ 'New-CSR', 'New-PrivateKey', 'New-SCEPmanCertificate', - 'New-SCEPManKeyVaultCertificate' + 'New-SCEPManKeyVaultCertificate', + 'Revoke-SCEPmanCertificate' ) } From cbc2c0e593d7d4a5fb06767b94342aa9ce910b05 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:25:15 +0200 Subject: [PATCH 02/20] Improve error handling by parsing internal error codes --- .../Public/Revoke-SCEPmanCertificate.ps1 | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index b11ce88..6c3a8de 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -118,21 +118,48 @@ Function Revoke-SCEPmanCertificate { Write-Verbose "$($MyInvocation.MyCommand): Sending revocation request to $RequestUrl" - try { + $Result = try { $Response = Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body + [pscustomobject]@{ + Success = $true + StatusCode = 200 + ErrorCode = $null + ErrorMessage = $null + } + Write-Verbose "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." $Response } catch { - $StatusCode = $_.Exception.Response.StatusCode.value__ - Write-Error "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. Status code: $StatusCode. Error details: $($_ | Out-String)" - - switch ($StatusCode) { - 401 { throw "$($MyInvocation.MyCommand): Unauthorized. Authentication failed. $_" } - 400 { throw "$($MyInvocation.MyCommand): Bad request. Check the request body for errors. $_" } - 404 { throw "$($MyInvocation.MyCommand): Certificate not found. Verify the URL and that serial number '$Serial' exists. $_" } - 500 { throw "$($MyInvocation.MyCommand): Server error. The certificate may have already been revoked. $_" } - default { throw $_ } + $statusCode = [int]$_.Exception.Response.StatusCode + $errorBody = $_.ErrorDetails.Message + + $errorCode = $null + $errorMessage = $null + + if ($errorBody) { + try { + $parsed = $errorBody | ConvertFrom-Json + $errorCode = $parsed.ErrorCode + $errorMessage = $parsed.ErrorMessage + } + catch { + $errorMessage = $errorBody + } + } + + [pscustomobject]@{ + Success = $false + StatusCode = $statusCode + ErrorCode = $errorCode + ErrorMessage = $errorMessage } + + } + + If ($Result.Success) { + Write-Output "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." + } Else { + throw "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. StatusCode: $($Result.StatusCode), ErrorCode: $($Result.ErrorCode), Message: $($Result.ErrorMessage)" } } } From b9e6f6ef2b959af91d3d3aeb51ad7b663434b696 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:25:44 +0200 Subject: [PATCH 03/20] Implement Find-SCEPmanCertificate --- SCEPmanClient/Private/x509/constants.ps1 | 15 ++ .../Public/Find-SCEPmanCertificate.ps1 | 177 ++++++++++++++++++ SCEPmanClient/SCEPmanClient.psd1 | 3 +- 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 diff --git a/SCEPmanClient/Private/x509/constants.ps1 b/SCEPmanClient/Private/x509/constants.ps1 index 3999210..e7379ce 100644 --- a/SCEPmanClient/Private/x509/constants.ps1 +++ b/SCEPmanClient/Private/x509/constants.ps1 @@ -58,4 +58,19 @@ enum RevocationReason { RemoveFromCrl = 8 PrivilegeWithdrawn = 9 AACompromise = 10 +} + +enum CertValidityType { + Valid + Revoked + Expired + Any +} + +enum CertType { + Static + DC + User + Device + Any } \ No newline at end of file diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 new file mode 100644 index 0000000..7b4108a --- /dev/null +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -0,0 +1,177 @@ +<# +.SYNOPSIS + Search certificates issued by SCEPman. + +.DESCRIPTION + This function searches certificates via the SCEPman management search API. + +.PARAMETER Url + The URL of the SCEPman App Service. + +.PARAMETER SearchText + Search text used by the SCEPman API (for example an email, subject, or serial fragment). + +.PARAMETER PageSize + Number of results to return per request. + +.PARAMETER CertValidity + Certificate validity filter value expected by your SCEPman API (for example 'Any' or a numeric enum value). + +.PARAMETER CertType + Certificate type filter value expected by your SCEPman API (for example 'Any' or a numeric enum value). + +.PARAMETER ContinuationToken + Continuation token from a previous search response. + +.PARAMETER ResourceUrl + The resource URL of the SCEPman service. If not provided, the function will try to find the Enterprise Application for the URL. + +.PARAMETER IgnoreExistingSession + Ignore existing Azure session. + +.PARAMETER DeviceCode + Use device code authentication. + +.PARAMETER Identity + Use the managed identity for authentication. + +.PARAMETER ClientId + The client ID for service principal authentication. + +.PARAMETER TenantId + The tenant ID for service principal authentication. + +.PARAMETER ClientSecret + The client secret for service principal authentication. + +.EXAMPLE + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice@contoso.com" -PageSize 50 -CertValidity Any -CertType Any + +.EXAMPLE + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice" -ContinuationToken "next-page-token" +#> + +Function Find-SCEPmanCertificate { + [CmdletBinding()] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUsernameAndPasswordParams", "", Justification="Service principal authentication requires username and password.")] + Param( + [Parameter(Mandatory, Position=0)] + [Alias('AppServiceUrl')] + [String]$Url, + + [String]$SearchText, + + [ValidateRange(1, 500)] + [Int]$PageSize = 50, + + [CertValidityType]$CertValidity = 'Any', + + [CertType]$CertType = 'Any', + + [String]$ContinuationToken, + + [String]$ResourceUrl, + + [Switch]$IgnoreExistingSession, + [Switch]$DeviceCode, + [Switch]$Identity, + [String]$ClientId, + [String]$TenantId, + [String]$ClientSecret + ) + + Begin { + $ErrorActionPreference = 'Stop' + + Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null + + $Connect_Params = @{} + + If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } + If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } + If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } + If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } + If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } + If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } + + Connect-SCEPmanAzAccount @Connect_Params + + If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { + Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" + $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url + } + + $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + } + + Process { + $BaseUrl = $Url.TrimEnd('/') + + $Headers = @{ + 'Authorization' = "Bearer $AccessToken" + } + + $Query = [ordered]@{ + SearchText = $SearchText + PageSize = $PageSize + CertValidity = $CertValidity + CertType = $CertType + ContinuationToken = $ContinuationToken + } + + $QueryString = ($Query.GetEnumerator() | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Value) } | + ForEach-Object { + '{0}={1}' -f [uri]::EscapeDataString($_.Key), [uri]::EscapeDataString([string]$_.Value) + }) -join '&' + + $RequestUrl = "$BaseUrl/api/manage/search" + If (-not [string]::IsNullOrWhiteSpace($QueryString)) { + $RequestUrl = "{0}?{1}" -f $RequestUrl, $QueryString + } + + Write-Verbose "$($MyInvocation.MyCommand): Sending search request to $RequestUrl" + + try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method Get -Headers $Headers + Write-Verbose "$($MyInvocation.MyCommand): Search request successful. Found $($Response.items.count) certificate$(if($Response.items.count -ne 1) { 's' })" + + Return $Response + + } catch { + $StatusCode = [int]$_.Exception.Response.StatusCode + $RawErrorBody = $_.ErrorDetails.Message + + $ApiErrorCode = $null + $ApiErrorMessage = $null + + if ($RawErrorBody) { + try { + $ParsedError = $RawErrorBody | ConvertFrom-Json + $ApiErrorCode = $ParsedError.ErrorCode + $ApiErrorMessage = $ParsedError.ErrorMessage + } + catch { + $ApiErrorMessage = $RawErrorBody + } + } + + Write-Error "$($MyInvocation.MyCommand): Failed to search certificates. Status code: $StatusCode. ApiErrorCode: $ApiErrorCode. ApiErrorMessage: $ApiErrorMessage" + + switch ($ApiErrorCode) { + 4711 { throw "$($MyInvocation.MyCommand): SCEPman Enterprise is required for the manage search API." } + default { + switch ($StatusCode) { + 400 { throw "$($MyInvocation.MyCommand): Bad request. Check the request parameters for errors. $ApiErrorMessage" } + 401 { throw "$($MyInvocation.MyCommand): Unauthorized. Authentication failed." } + 403 { throw "$($MyInvocation.MyCommand): Forbidden. Access denied or license revoked." } + 404 { throw "$($MyInvocation.MyCommand): Endpoint not found. Verify the URL and that the manage API endpoint exists." } + 409 { throw "$($MyInvocation.MyCommand): Conflict. $($ApiErrorMessage ? $ApiErrorMessage : 'Request could not be completed.')" } + 500 { throw "$($MyInvocation.MyCommand): Server error while searching certificates. $ApiErrorMessage" } + default { throw $_ } + } + } + } + } + } +} diff --git a/SCEPmanClient/SCEPmanClient.psd1 b/SCEPmanClient/SCEPmanClient.psd1 index 496cc47..5bd5285 100644 --- a/SCEPmanClient/SCEPmanClient.psd1 +++ b/SCEPmanClient/SCEPmanClient.psd1 @@ -36,7 +36,8 @@ 'New-PrivateKey', 'New-SCEPmanCertificate', 'New-SCEPManKeyVaultCertificate', - 'Revoke-SCEPmanCertificate' + 'Revoke-SCEPmanCertificate', + 'Find-SCEPmanCertificate' ) } From 23b69770ae999115304b620e0d64225fa9d04e56 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:25:53 +0200 Subject: [PATCH 04/20] Add tests --- Tests/Find-SCEPmanCertificate.Tests.ps1 | 55 ++++++++++++++++++ Tests/Revoke-SCEPmanCertificate.Tests.ps1 | 69 +++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 Tests/Find-SCEPmanCertificate.Tests.ps1 create mode 100644 Tests/Revoke-SCEPmanCertificate.Tests.ps1 diff --git a/Tests/Find-SCEPmanCertificate.Tests.ps1 b/Tests/Find-SCEPmanCertificate.Tests.ps1 new file mode 100644 index 0000000..f91cfc1 --- /dev/null +++ b/Tests/Find-SCEPmanCertificate.Tests.ps1 @@ -0,0 +1,55 @@ +BeforeAll { + $ModuleRoot = "$PSScriptRoot\..\SCEPmanClient\" + + Import-Module "$ModuleRoot\SCEPmanClient.psm1" -Force +} + +Describe "Find-SCEPmanCertificate" { + BeforeEach { + $script:LastInvokeUri = $null + $script:LastInvokeMethod = $null + $script:LastInvokeAuth = $null + + Mock Set-AzConfig {} -ModuleName SCEPmanClient + Mock Connect-SCEPmanAzAccount {} -ModuleName SCEPmanClient + Mock Get-SCEPmanResourceUrl { 'api://resource-id' } -ModuleName SCEPmanClient + Mock Get-SCEPmanAccessToken { 'test-token' } -ModuleName SCEPmanClient + Mock Invoke-RestMethod { + param($Uri, $Method, $Headers) + $script:LastInvokeUri = $Uri + $script:LastInvokeMethod = $Method + $script:LastInvokeAuth = $Headers.Authorization + [pscustomobject]@{ ok = $true } + } -ModuleName SCEPmanClient + } + + It "builds the search query and calls the API using bearer auth" { + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice@contoso.com" -PageSize 50 -CertValidity "Any" -CertType "Any" | Out-Null + + Should -Invoke Invoke-RestMethod -Times 1 -ModuleName SCEPmanClient + $script:LastInvokeMethod | Should -Be 'Get' + $script:LastInvokeAuth | Should -Be 'Bearer test-token' + $script:LastInvokeUri | Should -Match '^https://scepman\.contoso\.com/api/manage/search\?' + $script:LastInvokeUri | Should -Match 'SearchText=alice%40contoso\.com' + $script:LastInvokeUri | Should -Match 'PageSize=50' + $script:LastInvokeUri | Should -Match 'CertValidity=Any' + $script:LastInvokeUri | Should -Match 'CertType=Any' + } + + It "omits empty continuation token and resolves resource URL when not provided" { + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice" -ContinuationToken "" | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 1 -ModuleName SCEPmanClient + Should -Invoke Invoke-RestMethod -Times 1 -ModuleName SCEPmanClient + $script:LastInvokeUri | Should -Not -Match 'ContinuationToken=' + } + + It "does not resolve resource URL when ResourceUrl is provided" { + Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice" -ResourceUrl "api://given-resource" | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 0 -ModuleName SCEPmanClient + Should -Invoke Get-SCEPmanAccessToken -Times 1 -ModuleName SCEPmanClient -ParameterFilter { + $ResourceUrl -eq 'api://given-resource' + } + } +} diff --git a/Tests/Revoke-SCEPmanCertificate.Tests.ps1 b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 new file mode 100644 index 0000000..07c269e --- /dev/null +++ b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 @@ -0,0 +1,69 @@ +BeforeAll { + $ModuleRoot = "$PSScriptRoot\..\SCEPmanClient\" + + Import-Module "$ModuleRoot\SCEPmanClient.psm1" -Force +} + +Describe "Revoke-SCEPmanCertificate" { + BeforeEach { + $script:InvokeCalls = @() + + Mock Set-AzConfig {} -ModuleName SCEPmanClient + Mock Connect-SCEPmanAzAccount {} -ModuleName SCEPmanClient + Mock Get-SCEPmanResourceUrl { 'api://resource-id' } -ModuleName SCEPmanClient + Mock Get-SCEPmanAccessToken { 'test-token' } -ModuleName SCEPmanClient + Mock Get-AzContext { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'context-user@contoso.com' } } } -ModuleName SCEPmanClient + + Mock Invoke-RestMethod { + param($Uri, $Method, $Headers, $Body) + + $script:InvokeCalls += [pscustomobject]@{ + Uri = $Uri + Method = $Method + Auth = $Headers.Authorization + CType = $Headers.'Content-Type' + Body = $Body | ConvertFrom-Json + } + + [pscustomobject]@{ status = 'ok' } + } -ModuleName SCEPmanClient + } + + It "sends a PATCH request with revocation reason and explicit revoker" { + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D" -RevocationReason KeyCompromise -Revoker "admin@contoso.com" -ResourceUrl "api://given-resource" | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 0 -ModuleName SCEPmanClient + Should -Invoke Get-SCEPmanAccessToken -Times 1 -ModuleName SCEPmanClient -ParameterFilter { + $ResourceUrl -eq 'api://given-resource' + } + Should -Invoke Invoke-RestMethod -Times 1 -ModuleName SCEPmanClient + + $script:InvokeCalls[0].Method | Should -Be 'Patch' + $script:InvokeCalls[0].Auth | Should -Be 'Bearer test-token' + $script:InvokeCalls[0].CType | Should -Be 'application/json' + $script:InvokeCalls[0].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/1A2B3C4D' + $script:InvokeCalls[0].Body.revocationReason | Should -Be 1 + $script:InvokeCalls[0].Body.revoker | Should -Be 'admin@contoso.com' + } + + It "uses Azure context account as revoker when Revoker is not provided" { + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com/" -SerialNumber "A1B2" -RevocationReason Superseded | Out-Null + + Should -Invoke Get-SCEPmanResourceUrl -Times 1 -ModuleName SCEPmanClient + Should -Invoke Get-AzContext -Times 1 -ModuleName SCEPmanClient + $script:InvokeCalls[0].Body.revocationReason | Should -Be 4 + $script:InvokeCalls[0].Body.revoker | Should -Be 'context-user@contoso.com' + $script:InvokeCalls[0].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/A1B2' + } + + It "sends one request per serial number" { + Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1111", "2222" -RevocationReason Unspecified -Revoker "admin@contoso.com" | Out-Null + + Should -Invoke Invoke-RestMethod -Times 2 -ModuleName SCEPmanClient + $script:InvokeCalls.Count | Should -Be 2 + $script:InvokeCalls[0].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/1111' + $script:InvokeCalls[1].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/2222' + $script:InvokeCalls[0].Body.revocationReason | Should -Be 0 + $script:InvokeCalls[1].Body.revocationReason | Should -Be 0 + } +} From d4593ac3c9b486943d1dda2bc7ae8255acfbbe04 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:28:00 +0200 Subject: [PATCH 05/20] Add manage API to readme --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 7848224..3b959f1 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,31 @@ Please note that this requires additional SCEPman configuration regarding the st - AppConfig:StaticValidation:AllowRenewals : true - AppConfig:StaticValidation:ReenrollmentAllowedCertificateTypes: Static,IntuneUser (Depending on the types intended for renewal) +## Search certificates +Use `Find-SCEPmanCertificate` to query certificates via the SCEPman management API. + +```powershell +Find-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SearchText 'alice@contoso.com' -PageSize 50 -CertValidity Any -CertType Any +``` + +When paginating, pass the continuation token returned by the previous response: + +```powershell +Find-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SearchText 'alice' -ContinuationToken '' +``` + +## Revoke certificates +Use `Revoke-SCEPmanCertificate` to revoke one or more certificates in SCEPman Enterprise. + +Revoke a single certificate with an explicit revoker identity: + +```powershell +Revoke-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SerialNumber '1A2B3C4D' -RevocationReason KeyCompromise -Revoker 'admin@contoso.com' +``` + +Revoke multiple certificates at once: + +```powershell +Revoke-SCEPmanCertificate -Url 'https://scepman.contoso.com' -SerialNumber '1A2B3C4D','5E6F7A8B' -RevocationReason Superseded +``` + From ea9dcbf5456f5945e32e34b0c4fad66875fbca73 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:41:29 +0200 Subject: [PATCH 06/20] Remove unnecessary output --- SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 6c3a8de..088c125 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -128,7 +128,6 @@ Function Revoke-SCEPmanCertificate { } Write-Verbose "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." - $Response } catch { $statusCode = [int]$_.Exception.Response.StatusCode $errorBody = $_.ErrorDetails.Message From 59353db23fcfd8e89300588279a15378a5d8095e Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:41:29 +0200 Subject: [PATCH 07/20] Remove unnecessary output --- SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 6c3a8de..e2c5813 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -119,7 +119,7 @@ Function Revoke-SCEPmanCertificate { Write-Verbose "$($MyInvocation.MyCommand): Sending revocation request to $RequestUrl" $Result = try { - $Response = Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body + Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body [pscustomobject]@{ Success = $true StatusCode = 200 @@ -128,7 +128,6 @@ Function Revoke-SCEPmanCertificate { } Write-Verbose "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." - $Response } catch { $statusCode = [int]$_.Exception.Response.StatusCode $errorBody = $_.ErrorDetails.Message From 2a51239effaad97eabc507e5e225af8f3285b827 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Fri, 22 May 2026 15:47:15 +0200 Subject: [PATCH 08/20] Don't force import in tests --- Tests/Find-SCEPmanCertificate.Tests.ps1 | 2 +- Tests/Revoke-SCEPmanCertificate.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Find-SCEPmanCertificate.Tests.ps1 b/Tests/Find-SCEPmanCertificate.Tests.ps1 index f91cfc1..e56bbe5 100644 --- a/Tests/Find-SCEPmanCertificate.Tests.ps1 +++ b/Tests/Find-SCEPmanCertificate.Tests.ps1 @@ -1,7 +1,7 @@ BeforeAll { $ModuleRoot = "$PSScriptRoot\..\SCEPmanClient\" - Import-Module "$ModuleRoot\SCEPmanClient.psm1" -Force + Import-Module "$ModuleRoot\SCEPmanClient.psm1" } Describe "Find-SCEPmanCertificate" { diff --git a/Tests/Revoke-SCEPmanCertificate.Tests.ps1 b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 index 07c269e..bf20db1 100644 --- a/Tests/Revoke-SCEPmanCertificate.Tests.ps1 +++ b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 @@ -1,7 +1,7 @@ BeforeAll { $ModuleRoot = "$PSScriptRoot\..\SCEPmanClient\" - Import-Module "$ModuleRoot\SCEPmanClient.psm1" -Force + Import-Module "$ModuleRoot\SCEPmanClient.psm1" } Describe "Revoke-SCEPmanCertificate" { From 81ed06323394a63012036d75d0b52d755884e53f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 13:48:35 +0000 Subject: [PATCH 09/20] fix: null-check HTTP response in find certificate error handling Agent-Logs-Url: https://github.com/scepman/scepmanclient/sessions/08693e2f-f1f5-4922-bc32-f2500efc3257 Co-authored-by: cheinzler-gk <191097678+cheinzler-gk@users.noreply.github.com> --- SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 index 7b4108a..2005265 100644 --- a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -139,7 +139,7 @@ Function Find-SCEPmanCertificate { Return $Response } catch { - $StatusCode = [int]$_.Exception.Response.StatusCode + $StatusCode = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { $null } $RawErrorBody = $_.ErrorDetails.Message $ApiErrorCode = $null From 760244eb991ede2a2f437fac6f480775db77d8df Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Sun, 24 May 2026 11:59:25 +0200 Subject: [PATCH 10/20] Remove tertiary operator for PS5 compatability --- SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 index 7b4108a..1721955 100644 --- a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -166,7 +166,7 @@ Function Find-SCEPmanCertificate { 401 { throw "$($MyInvocation.MyCommand): Unauthorized. Authentication failed." } 403 { throw "$($MyInvocation.MyCommand): Forbidden. Access denied or license revoked." } 404 { throw "$($MyInvocation.MyCommand): Endpoint not found. Verify the URL and that the manage API endpoint exists." } - 409 { throw "$($MyInvocation.MyCommand): Conflict. $($ApiErrorMessage ? $ApiErrorMessage : 'Request could not be completed.')" } + 409 { throw "$($MyInvocation.MyCommand): Conflict. $(if ($ApiErrorMessage) { $ApiErrorMessage } else { 'Request could not be completed.' })" } 500 { throw "$($MyInvocation.MyCommand): Server error while searching certificates. $ApiErrorMessage" } default { throw $_ } } From 85605c07f9120cd52e59d9aa9060c2a1fc183adb Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Sun, 24 May 2026 11:59:53 +0200 Subject: [PATCH 11/20] Add simpler error handling for PS5 --- SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index e2c5813..82f2ca0 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -146,6 +146,11 @@ Function Revoke-SCEPmanCertificate { } } + # If we could not retrieve the internal error code/message, throw the raw error for better visibility + if (-not $errorCode) { + throw "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. Raw error: $($_)" + } + [pscustomobject]@{ Success = $false StatusCode = $statusCode From 1f45dffaa21d2feeb9fb6c8b60a1218da64972cf Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:06:11 +0200 Subject: [PATCH 12/20] Remove possible return value from result --- SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 82f2ca0..af87b15 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -119,7 +119,7 @@ Function Revoke-SCEPmanCertificate { Write-Verbose "$($MyInvocation.MyCommand): Sending revocation request to $RequestUrl" $Result = try { - Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body + $null = Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body [pscustomobject]@{ Success = $true StatusCode = 200 From c93a1ba68c577054ec7ea969f87462ff62879d9e Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:06:39 +0200 Subject: [PATCH 13/20] Process Url and header in Begin scope --- SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index af87b15..353591b 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -98,9 +98,7 @@ Function Revoke-SCEPmanCertificate { $Revoker = (Get-AzContext).Account.Id Write-Verbose "$($MyInvocation.MyCommand): No revoker provided. Using current Azure context: $Revoker" } - } - Process { $BaseUrl = $Url.TrimEnd('/') $Headers = @{ @@ -108,6 +106,9 @@ Function Revoke-SCEPmanCertificate { 'Content-Type' = 'application/json' } + } + + Process { foreach ($Serial in $SerialNumber) { $RequestUrl = "$BaseUrl/api/manage/revoke/$Serial" From f96ecbdb8ffec3bbb007fe1233d70a90cb4de7cf Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:07:59 +0200 Subject: [PATCH 14/20] Remove initial error logging --- SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 index 1928834..7017476 100644 --- a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -156,7 +156,7 @@ Function Find-SCEPmanCertificate { } } - Write-Error "$($MyInvocation.MyCommand): Failed to search certificates. Status code: $StatusCode. ApiErrorCode: $ApiErrorCode. ApiErrorMessage: $ApiErrorMessage" + Write-Verbose "$($MyInvocation.MyCommand): Failed to search certificates. Status code: $StatusCode. ApiErrorCode: $ApiErrorCode. ApiErrorMessage: $ApiErrorMessage" switch ($ApiErrorCode) { 4711 { throw "$($MyInvocation.MyCommand): SCEPman Enterprise is required for the manage search API." } From 6915b9a0e8bcff5d59c6e4ea4793bb6cb03f3ed7 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:11:55 +0200 Subject: [PATCH 15/20] Remove default parameter values from query --- SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 index 7017476..2466b03 100644 --- a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -64,9 +64,9 @@ Function Find-SCEPmanCertificate { [ValidateRange(1, 500)] [Int]$PageSize = 50, - [CertValidityType]$CertValidity = 'Any', + [CertValidityType]$CertValidity, - [CertType]$CertType = 'Any', + [CertType]$CertType, [String]$ContinuationToken, From 5eb19829ad7d1811f0c24dd06e62ea4612b5995d Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:28:46 +0200 Subject: [PATCH 16/20] Context is already used by SCEPman. No need to duplicate it. --- SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 5 ----- 1 file changed, 5 deletions(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 353591b..149c2da 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -94,11 +94,6 @@ Function Revoke-SCEPmanCertificate { $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl - If (-not $PSBoundParameters.ContainsKey('Revoker')) { - $Revoker = (Get-AzContext).Account.Id - Write-Verbose "$($MyInvocation.MyCommand): No revoker provided. Using current Azure context: $Revoker" - } - $BaseUrl = $Url.TrimEnd('/') $Headers = @{ From 80efc0f04a48038ca0257de1a97b2c0c25922cb7 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:41:52 +0200 Subject: [PATCH 17/20] Prepare for direct token auth for SCEPman SaaS --- .../Public/Find-SCEPmanCertificate.ps1 | 50 +++++++++++++------ .../Public/Revoke-SCEPmanCertificate.ps1 | 50 +++++++++++++------ 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 index 2466b03..af41dc6 100644 --- a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -52,7 +52,7 @@ #> Function Find-SCEPmanCertificate { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName='AzAuth')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUsernameAndPasswordParams", "", Justification="Service principal authentication requires username and password.")] Param( [Parameter(Mandatory, Position=0)] @@ -70,38 +70,56 @@ Function Find-SCEPmanCertificate { [String]$ContinuationToken, + [Parameter(ParameterSetName='AzAuth')] [String]$ResourceUrl, + [Parameter(ParameterSetName='AzAuth')] [Switch]$IgnoreExistingSession, + [Parameter(ParameterSetName='AzAuth')] [Switch]$DeviceCode, + [Parameter(ParameterSetName='AzAuth')] [Switch]$Identity, + [Parameter(ParameterSetName='AzAuth')] [String]$ClientId, + [Parameter(ParameterSetName='AzAuth')] [String]$TenantId, - [String]$ClientSecret + [Parameter(ParameterSetName='AzAuth')] + [String]$ClientSecret, + + [Parameter(ParameterSetName='DirectTokenAuth')] + [String]$AccessToken ) Begin { $ErrorActionPreference = 'Stop' - Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null + If($PSCmdlet.ParameterSetName -eq 'DirectTokenAuth') { + Write-Verbose "$($MyInvocation.MyCommand): Using direct token authentication" - $Connect_Params = @{} + If (-not $AccessToken) { + throw "$($MyInvocation.MyCommand): AccessToken is required for direct token authentication" + } + } Else { + Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null - If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } - If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } - If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } - If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } - If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } - If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } + $Connect_Params = @{} - Connect-SCEPmanAzAccount @Connect_Params + If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } + If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } + If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } + If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } + If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } + If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } - If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { - Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" - $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url - } + Connect-SCEPmanAzAccount @Connect_Params - $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { + Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" + $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url + } + + $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + } } Process { diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 149c2da..45735d7 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -46,7 +46,7 @@ #> Function Revoke-SCEPmanCertificate { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName='AzAuth')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUsernameAndPasswordParams", "", Justification="Service principal authentication requires username and password.")] Param( [Parameter(Mandatory, Position=0)] @@ -61,38 +61,56 @@ Function Revoke-SCEPmanCertificate { [String]$Revoker, + [Parameter(ParameterSetName='AzAuth')] [String]$ResourceUrl, + [Parameter(ParameterSetName='AzAuth')] [Switch]$IgnoreExistingSession, + [Parameter(ParameterSetName='AzAuth')] [Switch]$DeviceCode, + [Parameter(ParameterSetName='AzAuth')] [Switch]$Identity, + [Parameter(ParameterSetName='AzAuth')] [String]$ClientId, + [Parameter(ParameterSetName='AzAuth')] [String]$TenantId, - [String]$ClientSecret + [Parameter(ParameterSetName='AzAuth')] + [String]$ClientSecret, + + [Parameter(ParameterSetName='DirectTokenAuth')] + [String]$AccessToken ) Begin { $ErrorActionPreference = 'Stop' - Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null + If($PSCmdlet.ParameterSetName -eq 'DirectTokenAuth') { + Write-Verbose "$($MyInvocation.MyCommand): Using direct token authentication" - $Connect_Params = @{} + If (-not $AccessToken) { + throw "$($MyInvocation.MyCommand): AccessToken is required for direct token authentication" + } + } Else { + Set-AzConfig -Scope Process -LoginExperienceV2 Off -DisplaySurveyMessage $false | Out-Null - If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } - If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } - If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } - If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } - If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } - If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } + $Connect_Params = @{} - Connect-SCEPmanAzAccount @Connect_Params + If ($PSBoundParameters.ContainsKey('IgnoreExistingSession')) { $Connect_Params['IgnoreExistingSession'] = $true } + If ($PSBoundParameters.ContainsKey('DeviceCode')) { $Connect_Params['DeviceCode'] = $true } + If ($PSBoundParameters.ContainsKey('Identity')) { $Connect_Params['Identity'] = $true } + If ($PSBoundParameters.ContainsKey('ClientId')) { $Connect_Params['ClientId'] = $ClientId } + If ($PSBoundParameters.ContainsKey('TenantId')) { $Connect_Params['TenantId'] = $TenantId } + If ($PSBoundParameters.ContainsKey('ClientSecret')) { $Connect_Params['ClientSecret'] = $ClientSecret } - If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { - Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" - $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url - } + Connect-SCEPmanAzAccount @Connect_Params - $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + If (-not $PSBoundParameters.ContainsKey('ResourceUrl')) { + Write-Verbose "$($MyInvocation.MyCommand): No resource URL provided. Trying to find Enterprise Application for URL: $Url" + $ResourceUrl = Get-SCEPmanResourceUrl -AppServiceUrl $Url + } + + $AccessToken = Get-SCEPmanAccessToken -ResourceUrl $ResourceUrl + } $BaseUrl = $Url.TrimEnd('/') From f487f928bfe5112e53c95ae6ed6144999dae17eb Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:45:28 +0200 Subject: [PATCH 18/20] Simplify error handling --- .../Public/Revoke-SCEPmanCertificate.ps1 | 47 ++----------------- 1 file changed, 3 insertions(+), 44 deletions(-) diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 45735d7..98d8072 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -132,52 +132,11 @@ Function Revoke-SCEPmanCertificate { Write-Verbose "$($MyInvocation.MyCommand): Sending revocation request to $RequestUrl" - $Result = try { + try { $null = Invoke-RestMethod -Uri $RequestUrl -Method Patch -Headers $Headers -Body $Body - [pscustomobject]@{ - Success = $true - StatusCode = 200 - ErrorCode = $null - ErrorMessage = $null - } - - Write-Verbose "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." - } catch { - $statusCode = [int]$_.Exception.Response.StatusCode - $errorBody = $_.ErrorDetails.Message - - $errorCode = $null - $errorMessage = $null - - if ($errorBody) { - try { - $parsed = $errorBody | ConvertFrom-Json - $errorCode = $parsed.ErrorCode - $errorMessage = $parsed.ErrorMessage - } - catch { - $errorMessage = $errorBody - } - } - - # If we could not retrieve the internal error code/message, throw the raw error for better visibility - if (-not $errorCode) { - throw "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. Raw error: $($_)" - } - - [pscustomobject]@{ - Success = $false - StatusCode = $statusCode - ErrorCode = $errorCode - ErrorMessage = $errorMessage - } - - } - - If ($Result.Success) { Write-Output "$($MyInvocation.MyCommand): Certificate $Serial revoked successfully." - } Else { - throw "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. StatusCode: $($Result.StatusCode), ErrorCode: $($Result.ErrorCode), Message: $($Result.ErrorMessage)" + } catch { + throw "$($MyInvocation.MyCommand): Failed to revoke certificate $Serial. Raw error: $($_)" } } } From 730d5b2bbda0cb10ef26534ee17471bda8d0a8e7 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 15:53:33 +0200 Subject: [PATCH 19/20] Remove obsolete test --- Tests/Revoke-SCEPmanCertificate.Tests.ps1 | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Tests/Revoke-SCEPmanCertificate.Tests.ps1 b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 index bf20db1..817e134 100644 --- a/Tests/Revoke-SCEPmanCertificate.Tests.ps1 +++ b/Tests/Revoke-SCEPmanCertificate.Tests.ps1 @@ -46,16 +46,6 @@ Describe "Revoke-SCEPmanCertificate" { $script:InvokeCalls[0].Body.revoker | Should -Be 'admin@contoso.com' } - It "uses Azure context account as revoker when Revoker is not provided" { - Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com/" -SerialNumber "A1B2" -RevocationReason Superseded | Out-Null - - Should -Invoke Get-SCEPmanResourceUrl -Times 1 -ModuleName SCEPmanClient - Should -Invoke Get-AzContext -Times 1 -ModuleName SCEPmanClient - $script:InvokeCalls[0].Body.revocationReason | Should -Be 4 - $script:InvokeCalls[0].Body.revoker | Should -Be 'context-user@contoso.com' - $script:InvokeCalls[0].Uri | Should -Be 'https://scepman.contoso.com/api/manage/revoke/A1B2' - } - It "sends one request per serial number" { Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1111", "2222" -RevocationReason Unspecified -Revoker "admin@contoso.com" | Out-Null From b461242a7f4c8ccec92c41d262e6a9693accf552 Mon Sep 17 00:00:00 2001 From: Constantin Heinzler Date: Wed, 27 May 2026 16:10:15 +0200 Subject: [PATCH 20/20] Adjust synopsis --- SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 | 3 +++ SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 index af41dc6..6118c74 100644 --- a/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Find-SCEPmanCertificate.ps1 @@ -44,6 +44,9 @@ .PARAMETER ClientSecret The client secret for service principal authentication. +.PARAMETER AccessToken + An access token for authentication. If not provided, the function will authenticate using Azure PowerShell + .EXAMPLE Find-SCEPmanCertificate -Url "https://scepman.contoso.com" -SearchText "alice@contoso.com" -PageSize 50 -CertValidity Any -CertType Any diff --git a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 index 98d8072..01ec095 100644 --- a/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 +++ b/SCEPmanClient/Public/Revoke-SCEPmanCertificate.ps1 @@ -15,7 +15,7 @@ The reason for revoking the certificate. .PARAMETER Revoker - The identity of the person or entity revoking the certificate (e.g. admin@contoso.com). If not provided, the current Azure context account will be used. + The identity of the person or entity revoking the certificate (e.g. admin@contoso.com). .PARAMETER ResourceUrl The resource URL of the SCEPman service. If not provided, the function will try to find the Enterprise Application for the URL. @@ -38,6 +38,9 @@ .PARAMETER ClientSecret The client secret for service principal authentication. +.PARAMETER AccessToken + An access token for authentication. If not provided, the function will authenticate using Azure PowerShell + .EXAMPLE Revoke-SCEPmanCertificate -Url "https://scepman.contoso.com" -SerialNumber "1A2B3C4D" -RevocationReason KeyCompromise -Revoker "admin@contoso.com"