Skip to content

Commit 30ff845

Browse files
authored
Merge pull request #209 from milestonesys/jh/HARM-11472
Add certificate-based authentication support for Login Providers
2 parents 5171767 + 2b5fec9 commit 30ff845

9 files changed

Lines changed: 711 additions & 370 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2026 Milestone Systems A/S
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
function InvokeVmsRestApi {
16+
<#
17+
.SYNOPSIS
18+
Invokes an authenticated request against the XProtect API Gateway REST endpoint.
19+
20+
.DESCRIPTION
21+
InvokeVmsRestApi is an internal helper that sends HTTP requests to Milestone's
22+
XProtect API Gateway REST API under the /rest/v1 route. It resolves the API
23+
Gateway URI from the registered services for the active site, adds a bearer
24+
authorization header from the current login token cache, and returns the
25+
deserialized response. When a dictionary body is supplied it is serialized to
26+
JSON before sending.
27+
28+
.PARAMETER Method
29+
Specifies the HTTP method used for the request. The default is Get.
30+
31+
.PARAMETER Body
32+
Specifies the payload to send with methods that accept a request body. A
33+
dictionary/hashtable is converted to JSON with depth 10 before transmission.
34+
35+
.PARAMETER ContentType
36+
Specifies the Content-Type header value to use when Body is present. The
37+
default is application/json.
38+
39+
.PARAMETER ResourcePath
40+
Specifies the relative API resource path under /rest/v1, for example
41+
'loginproviders' or 'loginproviders/{id}'.
42+
43+
.PARAMETER GatewayUri
44+
Specifies the base URI of the API Gateway service. If omitted, the function
45+
discovers the gateway URI from the registered services for the active site.
46+
47+
.EXAMPLE
48+
InvokeVmsRestApi -ResourcePath 'loginproviders'
49+
50+
Retrieves the collection of login providers from the registered API Gateway
51+
for the current site.
52+
53+
.NOTES
54+
Requires a valid login token from (Get-LoginSettings).IdentityTokenCache.
55+
#>
56+
[CmdletBinding()]
57+
param (
58+
[Parameter()]
59+
[string]
60+
$Method = 'Get',
61+
62+
[Parameter()]
63+
[object]
64+
$Body,
65+
66+
[Parameter()]
67+
[string]
68+
$ContentType = 'application/json',
69+
70+
[Parameter(Mandatory)]
71+
[Alias('Path')]
72+
[string]
73+
$ResourcePath,
74+
75+
[Parameter()]
76+
[uri]
77+
$GatewayUri
78+
)
79+
80+
begin {
81+
Assert-VmsRequirementsMet
82+
if (-not $MyInvocation.BoundParameters.ContainsKey('GatewayUri')) {
83+
$GatewayUri = (Get-RegisteredService -ServiceType 'e46b7bf9-03ce-44eb-bbdc-8ba16d0aaa80').UriArray | Select-Object -First 1
84+
}
85+
if ($null -eq $GatewayUri) {
86+
throw "No 'API Gateway Service' registered on site $((Get-VmsSite).Name)."
87+
}
88+
}
89+
90+
process {
91+
$uriBuilder = [uribuilder]$GatewayUri
92+
$uriBuilder.Path = $uriBuilder.Path.TrimEnd('/') + '/rest/v1/' + $ResourcePath.TrimStart('/')
93+
$requestParams = @{
94+
Uri = $uriBuilder.Uri
95+
Method = $Method
96+
Headers = @{
97+
Authorization = "Bearer $((Get-LoginSettings).IdentityTokenCache.Token)"
98+
}
99+
}
100+
if ($MyInvocation.BoundParameters.ContainsKey('Body')) {
101+
$requestParams.ContentType = $ContentType
102+
if ($Body -is [System.Collections.IDictionary]) {
103+
$requestParams.Body = [pscustomobject]$Body | ConvertTo-Json -Depth 10
104+
} else {
105+
$requestParams.Body = $Body.ToString()
106+
}
107+
}
108+
Invoke-RestMethod @requestParams
109+
}
110+
}

MilestonePSTools/Public/New-VmsLoginProvider.ps1

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ function New-VmsLoginProvider {
3131
[securestring]
3232
$ClientSecret,
3333

34+
[Parameter()]
35+
[ValidateVmsVersion('25.3')]
36+
[ValidateSet('SharedSecret', 'X509Thumbprint')]
37+
[string]
38+
$ClientSecretType,
39+
3440
[Parameter()]
3541
[string]
3642
$CallbackPath = '/signin-oidc',
@@ -64,9 +70,31 @@ function New-VmsLoginProvider {
6470
process {
6571
try {
6672
$credential = [pscredential]::new($ClientId, $ClientSecret)
67-
$folder = (Get-VmsManagementServer).LoginProviderFolder
68-
$serverTask = $folder.AddLoginProvider([guid]::Empty, $Name, $ClientId, $credential.GetNetworkCredential().Password, $CallbackPath, $Authority, $UserNameClaim, $Scopes, $PromptForLogin, $Enabled)
69-
$loginProvider = Get-VmsLoginProvider | Where-Object Path -eq $serverTask.Path
73+
if ($ClientSecretType -eq 'X509Thumbprint') {
74+
# The .NET SDK's AddLoginProvider method does not support the
75+
# clientSecretType field, so certificate-based authentication must
76+
# be configured directly against the API Gateway REST endpoint.
77+
$loginProviderBody = @{
78+
name = $Name
79+
clientId = $ClientId
80+
clientSecret = $credential.GetNetworkCredential().Password
81+
clientSecretType = $ClientSecretType
82+
authority = $Authority
83+
callbackPath = $CallbackPath
84+
scopes = $Scopes
85+
userNameClaimType = $UserNameClaim
86+
promptForLogin = $PromptForLogin
87+
enabled = $Enabled
88+
}
89+
$null = InvokeVmsRestApi -ResourcePath 'loginproviders' -Method 'POST' -Body $loginProviderBody
90+
$folder = (Get-VmsManagementServer).LoginProviderFolder
91+
$folder.ClearChildrenCache()
92+
$loginProvider = Get-VmsLoginProvider -Name $Name
93+
} else {
94+
$folder = (Get-VmsManagementServer).LoginProviderFolder
95+
$serverTask = $folder.AddLoginProvider([guid]::Empty, $Name, $ClientId, $credential.GetNetworkCredential().Password, $CallbackPath, $Authority, $UserNameClaim, $Scopes, $PromptForLogin, $Enabled)
96+
$loginProvider = Get-VmsLoginProvider | Where-Object Path -eq $serverTask.Path
97+
}
7098
if ($null -ne $loginProvider) {
7199
$loginProvider
72100
}

MilestonePSTools/Public/Set-VmsLoginProvider.ps1

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ function Set-VmsLoginProvider {
3737
[securestring]
3838
$ClientSecret,
3939

40+
[Parameter()]
41+
[ValidateVmsVersion('25.3')]
42+
[ValidateSet('SharedSecret', 'X509Thumbprint')]
43+
[string]
44+
$ClientSecretType,
45+
4046
[Parameter()]
4147
[string]
4248
$CallbackPath,
@@ -96,11 +102,15 @@ function Set-VmsLoginProvider {
96102
}
97103
$dirty = $true
98104
}
99-
} elseif ($key -eq 'ClientSecret') {
105+
} elseif ($key -eq 'ClientSecret' -and $ClientSecretType -ne 'X509Thumbprint') {
100106
Write-Verbose "Updating $key on login provider '$initialName'"
101107
$cred = [pscredential]::new('a', $ClientSecret)
102108
$LoginProvider.ClientSecret = $cred.GetNetworkCredential().Password
103109
$dirty = $true
110+
} elseif ($key -eq 'ClientSecretType') {
111+
# The secret carries a certificate thumbprint and is applied
112+
# via the API Gateway below, not through the .NET SDK, so that
113+
# clientSecretType can be set to X509Thumbprint.
104114
} elseif ($key -eq 'Enabled' -and $LoginProvider.Enabled -ne $Enabled) {
105115
Write-Verbose "Setting Enabled to $Enabled on login provider '$initialName'"
106116
$LoginProvider.Enabled = $Enabled
@@ -117,9 +127,27 @@ function Set-VmsLoginProvider {
117127
}
118128
if ($dirty) {
119129
$LoginProvider.Save()
120-
} else {
130+
} elseif ($ClientSecretType -ne 'X509Thumbprint') {
121131
Write-Verbose "No changes were made to login provider '$initialName'."
122132
}
133+
134+
if ($ClientSecretType -eq 'X509Thumbprint') {
135+
if (-not $MyInvocation.BoundParameters.ContainsKey('ClientSecret')) {
136+
throw "The ClientSecret parameter is required when ClientSecretType is 'X509Thumbprint'. Provide the certificate thumbprint as the ClientSecret value."
137+
}
138+
# The .NET SDK does not support the clientSecretType field, so
139+
# certificate-based authentication is configured directly against
140+
# the API Gateway REST endpoint.
141+
Write-Verbose "Updating ClientSecret and ClientSecretType on login provider '$initialName' via the API Gateway"
142+
$cred = [pscredential]::new('a', $ClientSecret)
143+
$loginProviderBody = @{
144+
clientSecret = $cred.GetNetworkCredential().Password
145+
clientSecretType = $ClientSecretType
146+
}
147+
$null = InvokeVmsRestApi -ResourcePath "loginproviders/$($LoginProvider.Id)" -Method 'PATCH' -Body $loginProviderBody
148+
(Get-VmsManagementServer).LoginProviderFolder.ClearChildrenCache()
149+
$LoginProvider = Get-VmsLoginProvider -Name $LoginProvider.Name
150+
}
123151
}
124152

125153
if ($PassThru) {

0 commit comments

Comments
 (0)