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 +