Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .nuget/nuget.exe
Binary file not shown.
31 changes: 27 additions & 4 deletions Modules/SPE/Invoke-RemoteScript.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ function Invoke-RemoteScript {

.LINK
Stop-ScriptSession

.PARAMETER AccessToken
Specifies an external OAuth bearer token to use for authentication.
When provided, this value is sent as the Authorization: Bearer header.
#>
[CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName="InProcess")]
param(
Expand Down Expand Up @@ -220,6 +224,10 @@ function Invoke-RemoteScript {
[Parameter(ParameterSetName='Uri')]
[string]$SharedSecret,

[Parameter(ParameterSetName='Uri')]
[Parameter(ParameterSetName='Session')]
[string]$AccessToken,

[Parameter(ParameterSetName='Uri')]
[System.Management.Automation.PSCredential]
$Credential,
Expand Down Expand Up @@ -298,6 +306,9 @@ function Invoke-RemoteScript {
$Username = $Session.Username
$Password = $Session.Password
$SharedSecret = $Session.SharedSecret
if([string]::IsNullOrEmpty($AccessToken)) {
$AccessToken = $Session.AccessToken
}
$SessionId = $Session.SessionId
$Credential = $Session.Credential
$UseDefaultCredentials = $Session.UseDefaultCredentials
Expand All @@ -324,7 +335,9 @@ function Invoke-RemoteScript {
$handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$client = New-Object -TypeName System.Net.Http.Httpclient $handler

if(![string]::IsNullOrEmpty($SharedSecret)) {
if(![string]::IsNullOrEmpty($AccessToken)) {
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $AccessToken)
} elseif(![string]::IsNullOrEmpty($SharedSecret)) {
$token = New-Jwt -Algorithm 'HS256' -Issuer 'SPE Remoting' -Audience ($uri.GetLeftPart([System.UriPartial]::Authority)) -Name $Username -SecretKey $SharedSecret -ValidforSeconds 30
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
} else {
Expand Down Expand Up @@ -421,6 +434,9 @@ function Invoke-RemoteScriptAsync {
[Alias("ArgumentList")]
[hashtable]$Arguments,

[Parameter()]
[string]$AccessToken,

[Parameter()]
[switch]$Raw
)
Expand Down Expand Up @@ -477,6 +493,9 @@ function Invoke-RemoteScriptAsync {
$newScriptBlock = $scriptBlock.ToString()
$Username = $Session.Username
$Password = $Session.Password
if([string]::IsNullOrEmpty($AccessToken)) {
$AccessToken = $Session.AccessToken
}
$SessionId = $Session.SessionId
$Credential = $Session.Credential
$UseDefaultCredentials = $Session.UseDefaultCredentials
Expand All @@ -489,8 +508,12 @@ function Invoke-RemoteScriptAsync {
$handler = New-Object System.Net.Http.HttpClientHandler
$handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$client = New-Object -TypeName System.Net.Http.Httpclient $handler
$authBytes = [System.Text.Encoding]::GetEncoding("iso-8859-1").GetBytes("$($Username):$($Password)")
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Basic", [System.Convert]::ToBase64String($authBytes))
if(![string]::IsNullOrEmpty($AccessToken)) {
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $AccessToken)
} else {
$authBytes = [System.Text.Encoding]::GetEncoding("iso-8859-1").GetBytes("$($Username):$($Password)")
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Basic", [System.Convert]::ToBase64String($authBytes))
}

if ($Credential) {
$handler.Credentials = $Credential
Expand Down Expand Up @@ -531,4 +554,4 @@ function Invoke-RemoteScriptAsync {
})
Invoke-GenericMethod -InputObject $taskPost -MethodName ContinueWith -GenericType PSObject -ArgumentList $continuation,$localProps
}
}
}
12 changes: 11 additions & 1 deletion Modules/SPE/New-ScriptSession.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ function New-ScriptSession {
.PARAMETER SharedSecret
Specifies the SharedSecret used to authenticate the identity.

.PARAMETER AccessToken
Specifies an external OAuth bearer token (e.g. an XM Cloud access token) used for
authentication. When provided, Username/Password and SharedSecret are not required.

.PARAMETER Timeout
Specifies the duration of the wait, in seconds.

Expand All @@ -47,7 +51,8 @@ function New-ScriptSession {
#>
[CmdletBinding(DefaultParameterSetName="All")]
param(
[Parameter(Mandatory = $true)]
[Parameter(Mandatory = $true, ParameterSetName = "Password")]
[Parameter(Mandatory = $true, ParameterSetName = "SharedSecret")]
[ValidateNotNullOrEmpty()]
[string]$Username = $null,

Expand All @@ -59,6 +64,10 @@ function New-ScriptSession {
[ValidateNotNullOrEmpty()]
[string]$SharedSecret = $null,

[Parameter(Mandatory = $true, ParameterSetName = "AccessToken")]
[ValidateNotNullOrEmpty()]
[string]$AccessToken = $null,

[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[Uri[]]$ConnectionUri = $null,
Expand All @@ -84,6 +93,7 @@ function New-ScriptSession {
"Username" = [string]$Username
"Password" = [string]$Password
"SharedSecret" = [string]$SharedSecret
"AccessToken" = [string]$AccessToken
"SessionId" = [string]$sessionId
"Credential" = [System.Management.Automation.PSCredential]$Credential
"UseDefaultCredentials" = [bool]$UseDefaultCredentials
Expand Down
13 changes: 12 additions & 1 deletion Modules/SPE/Receive-RemoteItem.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ function Receive-RemoteItem {
[Parameter(ParameterSetName='Uri and Database')]
[string]$Password,

[Parameter(ParameterSetName='Uri and File')]
[Parameter(ParameterSetName='Uri and Database')]
[Parameter(ParameterSetName='Session and File')]
[Parameter(ParameterSetName='Session and Database')]
[string]$AccessToken,

[Parameter(ParameterSetName='Uri and File')]
[Parameter(ParameterSetName='Uri and Database')]
[System.Management.Automation.PSCredential]
Expand Down Expand Up @@ -137,6 +143,9 @@ function Receive-RemoteItem {
$Username = $Session.Username
$Password = $Session.Password
$SharedSecret = $Session.SharedSecret
if([string]::IsNullOrEmpty($AccessToken)) {
$AccessToken = $Session.AccessToken
}
$Credential = $Session.Credential
$UseDefaultCredentials = $Session.UseDefaultCredentials
$ConnectionUri = $Session | ForEach-Object { $_.Connection.BaseUri }
Expand Down Expand Up @@ -166,7 +175,9 @@ function Receive-RemoteItem {
$handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$client = New-Object -TypeName System.Net.Http.Httpclient $handler

if(![string]::IsNullOrEmpty($SharedSecret)) {
if(![string]::IsNullOrEmpty($AccessToken)) {
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $AccessToken)
} elseif(![string]::IsNullOrEmpty($SharedSecret)) {
$token = New-Jwt -Algorithm 'HS256' -Issuer 'SPE Remoting' -Audience ($uri.GetLeftPart([System.UriPartial]::Authority)) -Name $Username -SecretKey $SharedSecret -ValidforSeconds 30
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
} else {
Expand Down
11 changes: 10 additions & 1 deletion Modules/SPE/Send-RemoteItem.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ function Send-RemoteItem {
[Parameter(ParameterSetName='Uri')]
[string]$Password,

[Parameter(ParameterSetName='Uri')]
[Parameter(ParameterSetName='Session')]
[string]$AccessToken,

[Parameter(ParameterSetName='Uri')]
[System.Management.Automation.PSCredential]
$Credential,
Expand Down Expand Up @@ -143,6 +147,9 @@ function Send-RemoteItem {
$Username = $Session.Username
$Password = $Session.Password
$SharedSecret = $Session.SharedSecret
if([string]::IsNullOrEmpty($AccessToken)) {
$AccessToken = $Session.AccessToken
}
$Credential = $Session.Credential
$UseDefaultCredentials = $Session.UseDefaultCredentials
$ConnectionUri = $Session | ForEach-Object { $_.Connection.BaseUri }
Expand Down Expand Up @@ -174,7 +181,9 @@ function Send-RemoteItem {
$handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$client = New-Object -TypeName System.Net.Http.Httpclient $handler

if(![string]::IsNullOrEmpty($SharedSecret)) {
if(![string]::IsNullOrEmpty($AccessToken)) {
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $AccessToken)
} elseif(![string]::IsNullOrEmpty($SharedSecret)) {
$token = New-Jwt -Algorithm 'HS256' -Issuer 'SPE Remoting' -Audience ($uri.GetLeftPart([System.UriPartial]::Authority)) -Name $Username -SecretKey $SharedSecret -ValidforSeconds 30
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
} else {
Expand Down
125 changes: 125 additions & 0 deletions src/Spe/App_Config/Include/Spe/Spe.OAuthBearer.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<!--
Sitecore PowerShell Extensions
OAuthBearerTokenAuthenticationProvider configuration

PURPOSE
=======
This file is always deployed alongside the SPE assemblies. It registers
OAuthBearerTokenAuthenticationProvider as the active authentication provider,
replacing the default SharedSecretAuthenticationProvider, so that SPE remoting
can accept external OAuth / OIDC bearer tokens (e.g. an XM Cloud access token
with scope "xmcloud.cm:admin") instead of requiring a Username/Password or a
SharedSecret.

ACTIVATION
==========
The configuration patch inside this file is guarded by the Sitecore env:require
mechanism. It only takes effect when the following environment variable is set
to any non-empty value on the CM instance:

SITECORE_SPE_OAUTH=true

When the variable is absent the file is loaded by Sitecore but the entire
<sitecore> block is silently skipped, leaving the default
SharedSecretAuthenticationProvider in place. No manual rename or file
deployment change is needed — just set the environment variable in XM Cloud
(or in docker-compose / Azure App Service settings) to enable OAuth support.

SECURITY NOTES
==============
* JWT signature verification is intentionally skipped here because in XM Cloud
the token has already been validated by the edge/API-gateway layer before
reaching the CM instance. If you run SPE outside such a trusted proxy you
must validate signatures at the network boundary (e.g. API Management).
* Always configure allowedAudiences and requiredScopes to restrict which tokens
are accepted.
* Use requireSecureConnection="true" on the remoting service when possible.

USAGE EXAMPLE (PowerShell client)
==================================
$token = "<your-xmcloud-oauth-access-token>"
$session = New-ScriptSession -AccessToken $token -ConnectionUri "https://<cm-host>"
Invoke-RemoteScript -Session $session -ScriptBlock { Get-Item "master:\content\home" }
Stop-ScriptSession -Session $session
-->
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"
xmlns:role="http://www.sitecore.net/xmlconfig/role/"
xmlns:env="http://www.sitecore.net/xmlconfig/env/">
<sitecore role:require="Standalone or ContentManagement or XMCloud" env:require="SITECORE_SPE_OAUTH">
<powershell>
<authenticationProvider type="Spe.Core.Settings.Authorization.OAuthBearerTokenAuthenticationProvider, Spe">

<!--
allowedAudiences
At least one entry in the token's "aud" claim must match an entry here
(case-insensitive). Leave the list empty to accept any audience (not recommended).

NOTE: The audience ("aud" claim) identifies the *resource server* the token was
issued for — it is NOT the same as a scope. Typical forms are a URI such as
"https://your-cm-host.sitecorecloud.io" or a client/application ID assigned by
your IdP. Inspect an actual access token (e.g. paste it into jwt.io) to find
the exact value issued by your authorization server.

*** XM CLOUD ***
Decode a real XM Cloud access token and use the value(s) found in its "aud" claim.

*** NON-XM CLOUD OPERATORS ***
Replace the example below with the audience your Identity Server issues —
commonly a resource URI or ClientId configured in Sitecore Identity Server,
Azure AD, Okta, or your IdP of choice. Using the wrong value will silently
reject every token.

IMPORTANT: Removing all <audience> entries disables audience validation entirely.
Any bearer token with a valid expiry will then be accepted regardless of its
intended audience. Always configure at least one entry in production.
-->
<allowedAudiences hint="list">
<!-- Replace with the "aud" claim value from your actual access token.
Example: <audience>https://your-cm-host.sitecorecloud.io</audience> -->
</allowedAudiences>

<!--
requiredScopes
All listed scopes must be present in the token's "scope" claim.
Leave the list empty to skip scope validation (not recommended).

*** NON-XM CLOUD OPERATORS ***
The default value "xmcloud.cm:admin" is an XM Cloud-specific scope.
Replace it with the scope(s) your IdP issues — e.g. "openid sitecore.profile"
for Sitecore Identity Server, or a custom application scope for Azure AD / Okta.

IMPORTANT: Removing all <scope> entries disables scope validation entirely.
Always configure at least one meaningful scope in production.
-->
<requiredScopes hint="list">
<scope>xmcloud.cm:admin</scope>
</requiredScopes>

<!--
usernameClaim
Name of the JWT claim whose value becomes the Sitecore username when
serviceAccountUsername is not set. Common values: sub, email, preferred_username.
Ignored when serviceAccountUsername is configured.
-->
<usernameClaim>sub</usernameClaim>

<!--
serviceAccountUsername
When set, every successfully validated token causes SPE to switch to this
fixed Sitecore account, ignoring the token's identity claims.
Ideal for automation pipelines where a single service account runs all remote scripts.
Uncomment and set to the Sitecore account that should own remoting executions.
-->
<!--<serviceAccountUsername>sitecore\admin</serviceAccountUsername>-->

<!--
detailedAuthenticationErrors
Set to true to emit detailed rejection reasons to the Sitecore log.
Keep false in production to avoid leaking token information.
-->
<detailedAuthenticationErrors>false</detailedAuthenticationErrors>

</authenticationProvider>
</powershell>
</sitecore>
</configuration>
Loading