Skip to content

Re-implement OAuth bearer support from PR #1475 on feature/remoting #1476

@michaellwest

Description

@michaellwest

Context

PR #1475 (#1475) adds opt-in
OAuth bearer-token authentication to SPE remoting. The PR targets master,
but the feature/remoting branch has already diverged substantially in every
file the PR touches, so the diff cannot be cherry-picked as-is. This issue
tracks re-implementing the PR's intent on top of feature/remoting, using the
helpers and hardening already present on that branch.

Conflict points with feature/remoting:

Scope

Client (Modules/SPE/): add -AccessToken as a first-class auth mode
alongside Username/Password, SharedSecret (HMAC JWT), and AccessKeyId.

Server (src/Spe/Core/Settings/Authorization/): add
OAuthBearerTokenAuthenticationProvider that validates external JWTs
(exp, nbf, iat, aud, iss, scope, username claim), with JWT signature
verification via a configurable JWKS endpoint. Ship two env-var-gated Sitecore
config files: one to enable the provider, one to enable remoting endpoints.

Out of scope for this issue:

  • Resurrecting Invoke-RemoteScriptAsync.
  • Wiring item-based remoting policies (RemotingPolicyManager) to apply to
    bearer-token sessions. Deferred; leave an inline TODO and a follow-up
    issue.
  • .nuget/nuget.exe.

Design decisions

Question Decision
Item-based remoting policies for bearer sessions Defer. Bypass for now with inline TODO.
JWT signature verification JWKS on day one. SkipSignatureValidation remains as an explicit, loudly-logged opt-out; not the default.
Role scope on Spe.XMCloud.Remoting.config Standalone or ContentManagement or XMCloud (not XMCloud-only), so non-cloud operators can opt in by env var.

Client changes

Modules/SPE/SPE.psm1

  • Extend Expand-ScriptSession (lines 1-16) to surface AccessToken.
  • Extend New-SpeHttpClient (lines 18-67) with [string]$AccessToken. Add a
    3-way branch: AccessToken wins, then SharedSecret (HMAC JWT), then Basic.
    No JWT is minted client-side for bearer tokens because they are externally
    issued.

Modules/SPE/New-ScriptSession.ps1

  • New parameter set "AccessToken" with mandatory [string]$AccessToken.
  • Add "AccessToken" = [string]$AccessToken to the session hashtable.
  • Update .PARAMETER docs and add an .EXAMPLE for bearer-token use.

Modules/SPE/Invoke-RemoteScript.ps1, Send-RemoteItem.ps1, Receive-RemoteItem.ps1

  • Add [string]$AccessToken to the param block (valid in Uri and Session
    sets).
  • In the Session branch, unpack $sd.AccessToken when the caller did not
    pass one explicitly.
  • Pass -AccessToken $AccessToken to New-SpeHttpClient.
  • Update docstrings.

Server changes

Refactor: extract shared JWT claim helpers

Lift these private helpers from
SharedSecretAuthenticationProvider.cs:143-222 into a new
Core/Settings/Authorization/JwtClaimValidator.cs (internal static):
IsValidExpiration, IsValidNotBefore, IsValidIssuedAt,
IsValidTokenLifetime, IsValidAudience, IsValidIssuer, Decode. Both
providers use them. No behaviour change; ship in its own commit.

Core/Settings/Authorization/JwksKeyResolver.cs (new)

  • HTTP GET JwksUri, parse keys[], index by kid.
  • Cache per JwksUri with configurable TTL (default 600s); refresh on miss.
  • Return RSAParameters / ECParameters for a given kid + alg.
  • Fail closed on network errors; do not fall through to skip-signature.

Core/Settings/Authorization/OAuthBearerTokenAuthenticationProvider.cs (new)

Implements ISpeAuthenticationProviderEx (not only the base interface).

Configuration properties:

Property Default
AllowedAudiences empty
AllowedIssuers empty
RequiredScopes empty
UsernameClaim sub
ServiceAccountUsername null
AllowedAlgorithms RS256,RS384,RS512,ES256
JwksUri null
JwksCacheSeconds 600
SkipSignatureValidation false
MaxTokenLifetimeSeconds 3600
DetailedAuthenticationErrors false
SuppressWarnings false
ClockSkewSeconds 30

Validation pipeline (short-circuit on first failure):

  1. IsJwtShape - three dot-separated parts.
  2. Decode header, typ == "JWT", alg in allowlist.
  3. Decode payload JSON.
  4. exp, nbf, iat present and within bounds (via JwtClaimValidator).
  5. iss in AllowedIssuers.
  6. aud in AllowedAudiences (support string or array shape).
  7. Scope check - read scope (space-delimited string) or scp (array);
    assert each RequiredScopes entry is present, case-insensitive.
  8. Signature: JwksKeyResolver.GetKey(kid) then verify over
    header.payload. When SkipSignatureValidation=true, log a Warn
    [OAuthBearer] action=signatureSkipped and continue.
  9. Username: ServiceAccountUsername if set, else
    payload[UsernameClaim]?.ToString(). Fail if null/empty.

Log format matches the existing provider:
[JWT] action=validationFailed reason=... via PowerShellLog.Warn, with
LogSanitizer.SanitizeValue on any user-sourced value.

src/Spe/App_Config/Include/Spe/Spe.OAuthBearer.config (new)

  • Gated on env:require="SITECORE_SPE_OAUTH".
  • role:require="Standalone or ContentManagement or XMCloud".
  • <allowedAudiences>, <allowedIssuers>, <allowedAlgorithms>,
    <requiredScopes>, <usernameClaim>, <serviceAccountUsername>,
    <jwksUri>, <jwksCacheSeconds>, <skipSignatureValidation>,
    <maxTokenLifetimeSeconds>, <detailedAuthenticationErrors>.
  • Comments use ASCII hyphens only.

src/Spe/App_Config/Include/Spe/Spe.XMCloud.Remoting.config (new)

  • Three <sitecore> blocks, each gated on its own env var
    (SITECORE_SPE_REMOTING_ENABLED, SITECORE_SPE_REMOTING_FILE_ENABLED,
    SITECORE_SPE_REMOTING_MEDIA_ENABLED) via env:require.
  • Role scope: Standalone or ContentManagement or XMCloud.
  • ASCII-only punctuation.

src/Spe/Spe.csproj

Wire the new .config and .cs files.

Suggested commit structure

  1. Refactor: extract JwtClaimValidator from
    SharedSecretAuthenticationProvider. No behaviour change.
  2. Server: add JwksKeyResolver + OAuthBearerTokenAuthenticationProvider +
    both config files + csproj wiring.
  3. Client: add AccessToken to New-SpeHttpClient, Expand-ScriptSession,
    New-ScriptSession, Invoke-RemoteScript, Send-RemoteItem,
    Receive-RemoteItem.
  4. Docs: update docstrings and any touched README.

Each commit is independently buildable. (1) and (3) are independently useful.

Verification

  • task build after each commit.
  • task up && task deploy.
  • With SITECORE_SPE_OAUTH unset: existing shared-secret and API-key flows
    still work; log shows SharedSecretAuthenticationProvider active.
  • With SITECORE_SPE_OAUTH=true and a JWKS + audiences + issuers populated:
    OAuth provider active; a valid test token (RS256, matching iss/aud/
    scope) authenticates for all four remoting cmdlets.
  • Negative matrix - each must 401 and emit a distinct
    [JWT] action=validationFailed reason=... log line:
    expired exp, future nbf, future iat, lifetime over max, wrong iss,
    wrong aud, missing scope, alg not in allowlist, tampered signature,
    missing username claim, unresolvable kid.
  • SkipSignatureValidation=true emits a Warn on every request.
  • tests/integration/Run-RemotingTests.ps1 green.

Follow-ups

  • Separate issue: wire RemotingPolicyManager for bearer-token sessions.
  • Separate issue: consider Azure-AD-specific issuer formatting quirks if
    they surface in testing.

References

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions