diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe
new file mode 100644
index 000000000..94aada9ca
Binary files /dev/null and b/.nuget/nuget.exe differ
diff --git a/Modules/SPE/Invoke-RemoteScript.ps1 b/Modules/SPE/Invoke-RemoteScript.ps1
index 2f9a0452e..f13612138 100644
--- a/Modules/SPE/Invoke-RemoteScript.ps1
+++ b/Modules/SPE/Invoke-RemoteScript.ps1
@@ -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(
@@ -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,
@@ -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
@@ -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 {
@@ -421,6 +434,9 @@ function Invoke-RemoteScriptAsync {
[Alias("ArgumentList")]
[hashtable]$Arguments,
+ [Parameter()]
+ [string]$AccessToken,
+
[Parameter()]
[switch]$Raw
)
@@ -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
@@ -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
@@ -531,4 +554,4 @@ function Invoke-RemoteScriptAsync {
})
Invoke-GenericMethod -InputObject $taskPost -MethodName ContinueWith -GenericType PSObject -ArgumentList $continuation,$localProps
}
-}
\ No newline at end of file
+}
diff --git a/Modules/SPE/New-ScriptSession.ps1 b/Modules/SPE/New-ScriptSession.ps1
index 30286b4f6..546738014 100644
--- a/Modules/SPE/New-ScriptSession.ps1
+++ b/Modules/SPE/New-ScriptSession.ps1
@@ -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.
@@ -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,
@@ -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,
@@ -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
diff --git a/Modules/SPE/Receive-RemoteItem.ps1 b/Modules/SPE/Receive-RemoteItem.ps1
index 6e56c5603..e1675ccaf 100644
--- a/Modules/SPE/Receive-RemoteItem.ps1
+++ b/Modules/SPE/Receive-RemoteItem.ps1
@@ -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]
@@ -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 }
@@ -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 {
diff --git a/Modules/SPE/Send-RemoteItem.ps1 b/Modules/SPE/Send-RemoteItem.ps1
index 2fc7ee600..12e82b9eb 100644
--- a/Modules/SPE/Send-RemoteItem.ps1
+++ b/Modules/SPE/Send-RemoteItem.ps1
@@ -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,
@@ -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 }
@@ -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 {
diff --git a/src/Spe/App_Config/Include/Spe/Spe.OAuthBearer.config b/src/Spe/App_Config/Include/Spe/Spe.OAuthBearer.config
new file mode 100644
index 000000000..077f54a08
--- /dev/null
+++ b/src/Spe/App_Config/Include/Spe/Spe.OAuthBearer.config
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmcloud.cm:admin
+
+
+
+ sub
+
+
+
+
+
+ false
+
+
+
+
+
diff --git a/src/Spe/App_Config/Include/Spe/Spe.XMCloud.Remoting.config b/src/Spe/App_Config/Include/Spe/Spe.XMCloud.Remoting.config
new file mode 100644
index 000000000..96d3937f7
--- /dev/null
+++ b/src/Spe/App_Config/Include/Spe/Spe.XMCloud.Remoting.config
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+ true
+
+
+
+
+
+
diff --git a/src/Spe/Core/Settings/Authorization/OAuthBearerTokenAuthenticationProvider.cs b/src/Spe/Core/Settings/Authorization/OAuthBearerTokenAuthenticationProvider.cs
new file mode 100644
index 000000000..475e074d8
--- /dev/null
+++ b/src/Spe/Core/Settings/Authorization/OAuthBearerTokenAuthenticationProvider.cs
@@ -0,0 +1,264 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Web.Script.Serialization;
+using Spe.Abstractions.VersionDecoupling.Interfaces;
+using Spe.Core.Diagnostics;
+
+namespace Spe.Core.Settings.Authorization
+{
+ ///
+ /// Authentication provider that accepts external OAuth/OIDC bearer tokens (e.g. XM Cloud access tokens).
+ /// It validates the token's expiration, audience, and any required claims, then resolves the Sitecore
+ /// username from a configurable claim or a fixed service-account username.
+ ///
+ /// JWT signature verification is intentionally skipped because in XM Cloud and other Sitecore Cloud
+ /// topologies the token has already been validated by the platform's edge/API-gateway layer before it
+ /// reaches the CM instance. Operators that run SPE outside such a trusted proxy can front the endpoint
+ /// with their own gateway that validates the signature.
+ ///
+ public class OAuthBearerTokenAuthenticationProvider : ISpeAuthenticationProvider
+ {
+ // ── Configuration properties (populated from Sitecore config XML) ──────────────────────────────
+
+ ///
+ /// Audiences that are allowed. At least one of the values in the token's aud claim must
+ /// match an entry in this list (case-insensitive). Configured via
+ /// <allowedAudiences hint="list"><audience>…</audience></allowedAudiences>.
+ ///
+ public List AllowedAudiences { get; set; } = new List();
+
+ ///
+ /// Scope values that must ALL be present in the token's scope claim (space-delimited string
+ /// or array). Configured via
+ /// <requiredScopes hint="list"><scope>…</scope></requiredScopes>.
+ ///
+ public List RequiredScopes { get; set; } = new List();
+
+ ///
+ /// Name of the JWT claim whose value is used as the Sitecore username when
+ /// is not configured. Defaults to sub.
+ ///
+ public string UsernameClaim { get; set; } = "sub";
+
+ ///
+ /// When set, every successfully validated token causes this fixed Sitecore account to be used
+ /// regardless of the token's claims. Useful for automation scenarios where all callers should
+ /// run as a shared service account (e.g. sitecore\admin).
+ ///
+ public string ServiceAccountUsername { get; set; }
+
+ ///
+ /// When true, authentication errors include additional detail in log messages.
+ ///
+ public bool DetailedAuthenticationErrors { get; set; }
+
+ // ── ISpeAuthenticationProvider ───────────────────────────────────────────────────────────────
+
+ public bool Validate(string token, string authority, out string username)
+ {
+ username = null;
+
+ if (string.IsNullOrEmpty(token))
+ {
+ return false;
+ }
+
+ var parts = token.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 3)
+ {
+ LogDetail("OAuth bearer token is not a valid three-part JWT.");
+ return false;
+ }
+
+ // ── Decode header (not strictly needed here but validates structure) ──────────────────
+ string payloadJson;
+ try
+ {
+ payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1]));
+ }
+ catch (Exception ex)
+ {
+ LogDetail($"OAuth bearer token payload could not be decoded: {ex.Message}");
+ return false;
+ }
+
+ var serializer = new JavaScriptSerializer();
+ Dictionary payload;
+ try
+ {
+ payload = serializer.Deserialize>(payloadJson);
+ }
+ catch (Exception ex)
+ {
+ LogDetail($"OAuth bearer token payload could not be parsed: {ex.Message}");
+ return false;
+ }
+
+ if (!ValidateExpiration(payload)) return false;
+ if (!ValidateAudience(payload)) return false;
+ if (!ValidateScopes(payload)) return false;
+
+ username = ResolveUsername(payload);
+ if (string.IsNullOrEmpty(username))
+ {
+ LogDetail($"OAuth bearer token does not contain the required username claim '{UsernameClaim}'.");
+ return false;
+ }
+
+ PowerShellLog.Debug($"OAuthBearerTokenAuthenticationProvider: token accepted, resolved username '{username}'.");
+ return true;
+ }
+
+ // ── Validation helpers ───────────────────────────────────────────────────────────────────────
+
+ private bool ValidateExpiration(Dictionary payload)
+ {
+ if (!payload.TryGetValue("exp", out var expObj))
+ {
+ LogDetail("OAuth bearer token is missing the 'exp' claim.");
+ return false;
+ }
+
+ long exp;
+ try
+ {
+ exp = Convert.ToInt64(expObj);
+ }
+ catch
+ {
+ LogDetail("OAuth bearer token 'exp' claim could not be converted to a long.");
+ return false;
+ }
+
+ var expireUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(exp);
+ if (DateTime.UtcNow >= expireUtc)
+ {
+ LogDetail("OAuth bearer token has expired.");
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool ValidateAudience(Dictionary payload)
+ {
+ if (AllowedAudiences == null || AllowedAudiences.Count == 0)
+ {
+ // No audience restriction configured – allow any audience.
+ return true;
+ }
+
+ if (!payload.TryGetValue("aud", out var audObj))
+ {
+ LogDetail("OAuth bearer token is missing the 'aud' claim but audiences are required.");
+ return false;
+ }
+
+ // aud can be a single string or a JSON array.
+ var tokenAudiences = new List();
+ if (audObj is string s)
+ {
+ tokenAudiences.Add(s);
+ }
+ else if (audObj is System.Collections.ArrayList arr)
+ {
+ foreach (var item in arr)
+ {
+ if (item != null) tokenAudiences.Add(item.ToString());
+ }
+ }
+
+ foreach (var allowed in AllowedAudiences)
+ {
+ foreach (var tokenAud in tokenAudiences)
+ {
+ if (string.Equals(allowed, tokenAud, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ }
+
+ LogDetail($"OAuth bearer token audience does not match any allowed audience.");
+ return false;
+ }
+
+ private bool ValidateScopes(Dictionary payload)
+ {
+ if (RequiredScopes == null || RequiredScopes.Count == 0)
+ {
+ return true;
+ }
+
+ payload.TryGetValue("scope", out var scopeObj);
+ var tokenScopes = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (scopeObj is string scopeStr)
+ {
+ foreach (var s in scopeStr.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
+ tokenScopes.Add(s);
+ }
+ else if (scopeObj is System.Collections.ArrayList arr)
+ {
+ foreach (var item in arr)
+ {
+ if (item != null) tokenScopes.Add(item.ToString());
+ }
+ }
+
+ foreach (var required in RequiredScopes)
+ {
+ if (!tokenScopes.Contains(required))
+ {
+ LogDetail($"OAuth bearer token is missing required scope '{required}'.");
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private string ResolveUsername(Dictionary payload)
+ {
+ if (!string.IsNullOrEmpty(ServiceAccountUsername))
+ {
+ return ServiceAccountUsername;
+ }
+
+ var claimName = string.IsNullOrEmpty(UsernameClaim) ? "sub" : UsernameClaim;
+ payload.TryGetValue(claimName, out var claimValue);
+ return claimValue?.ToString();
+ }
+
+ // ── Utility ─────────────────────────────────────────────────────────────────────────────────
+
+ private void LogDetail(string message)
+ {
+ if (DetailedAuthenticationErrors)
+ {
+ PowerShellLog.Warn($"OAuthBearerTokenAuthenticationProvider: {message}");
+ }
+ else
+ {
+ PowerShellLog.Debug($"OAuthBearerTokenAuthenticationProvider: {message}");
+ }
+ }
+
+ private static byte[] Base64UrlDecode(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ throw new ArgumentException("Value cannot be null or whitespace.", nameof(input));
+
+ var output = input
+ .Replace('-', '+')
+ .Replace('_', '/');
+
+ switch (output.Length % 4)
+ {
+ case 2: output += "=="; break;
+ case 3: output += "="; break;
+ }
+
+ return Convert.FromBase64String(output);
+ }
+ }
+}
diff --git a/src/Spe/Spe.csproj b/src/Spe/Spe.csproj
index 1cfbc245d..4139fff0f 100644
--- a/src/Spe/Spe.csproj
+++ b/src/Spe/Spe.csproj
@@ -592,6 +592,7 @@
+
@@ -619,6 +620,7 @@
Designer
+