From 54922072fcefbe18de867f2c470ab38c1e561b24 Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Fri, 28 Aug 2026 11:35:29 +0100
Subject: [PATCH 1/9] Add inbound SHR PoP validation to the Entra Sidecar
(throwaway spike)
Re-hosts MISE.AuthN's SignedHttpRequestHandler validation to validate
inbound app-only Signed HTTP Request (SHR) Proof-of-Possession tokens at
the sidecar's /Validate endpoint, additively alongside the unchanged
Bearer path.
- Register a second "PoP" AuthenticationHandler; the global default scheme
stays "Bearer". Only /Validate opts into a [Bearer, PoP] authorization
policy, so every other endpoint and the Bearer path are unchanged.
- Validate the embedded access token with the same AzureAd/JwtBearer
TokenValidationParameters (one source of truth for issuer/audience/
signing-keys/lifetime); clone per request and propagate the
ConfigurationManager for order-independent JWKS key resolution.
- Timestamp-only (ts) freshness with MISE-compatible default flags
(m/u/p/ts on; q/h/b off; 5-min lifetime), operator-configurable via a
new Sidecar:PopValidation options block (AOT-safe: bind DTO, map in code).
- Emit WWW-Authenticate: PoP error="invalid_token" only for PoP-scheme
requests; log the failure reason server-side at Information (parity with
Bearer). No error_description over the wire.
- Tests: ARM-shaped round-trip via an in-process mock IdP (live discovery/
JWKS), outer-SHR and inner-AT negative matrix, config default/override
mapping, server-side-log assertion, and a Bearer-unchanged guard.
Scope: app-only tokens, timestamp-only. Server nonce, OBO/actor, PFT/CDT,
and mTLS PoP are out of scope. Throwaway spike - not for production; no
PR/merge intended.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Configuration/PopValidationOptions.cs | 102 ++++
.../Configuration/SidecarOptions.cs | 7 +
.../Endpoints/ValidateRequestEndpoints.cs | 22 +-
.../Logging/Logging.cs | 10 +
.../Microsoft.Identity.Web.Sidecar.csproj | 2 +
.../Pop/PopAuthenticationExtensions.cs | 44 ++
.../Pop/PopAuthenticationHandler.cs | 104 ++++
.../Pop/PopConstants.cs | 42 ++
.../Pop/PopHttpRequestFactory.cs | 56 +++
.../Pop/ShrPopValidationResult.cs | 31 ++
.../Pop/ShrPopValidationService.cs | 134 ++++++
src/Microsoft.Identity.Web.Sidecar/Program.cs | 13 +-
.../Properties/InternalsVisibleTo.cs | 8 +
.../E2E Tests/Sidecar.Tests/MockIdpServer.cs | 205 ++++++++
.../Sidecar.Tests/OutboundRedirectTests.cs | 5 +-
.../Sidecar.Tests/PopSidecarApiFactory.cs | 80 ++++
.../E2E Tests/Sidecar.Tests/PopTestCrypto.cs | 213 +++++++++
.../PopValidationOptionsMappingTests.cs | 95 ++++
.../Sidecar.Tests/ShrPopValidationTests.cs | 444 ++++++++++++++++++
.../Sidecar.Tests/Sidecar.Tests.csproj | 2 +
20 files changed, 1614 insertions(+), 5 deletions(-)
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs
create mode 100644 tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs
create mode 100644 tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs
create mode 100644 tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
create mode 100644 tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
create mode 100644 tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
new file mode 100644
index 000000000..456b0f231
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+
+namespace Microsoft.Identity.Web.Sidecar.Configuration;
+
+///
+/// SPIKE (throwaway): operator-tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession
+/// validation, bound from the Sidecar:PopValidation configuration subsection. Every property
+/// defaults to the strict MISE SignedHttpRequestValidationOptions default, so when the subsection
+/// is absent the mapped SignedHttpRequestValidationParameters are the current behavior (m/u/p/ts
+/// ON; q/h OFF; unsigned headers/query accepted; 5-minute lifetime; nonce unset).
+///
+/// Only the config-clean members Wilson exposes as simple values are surfaced here. Deliberately NOT
+/// configurable (see the design doc): body-hash b (needs request-body buffering; MISE never
+/// surfaces it), jku key resolution (introduces outbound egress; the cnf jwk-vs-jku open item),
+/// and all delegate members (nonce/replay/signature/key-resolver) which are code, not configuration.
+///
+///
+public class PopValidationOptions
+{
+ ///
+ /// Gets or sets a value indicating whether the ts (timestamp) claim is validated. This is the
+ /// replay/freshness guard. Default true; disabling it removes replay protection.
+ ///
+ [DefaultValue(true)]
+ public bool ValidateTs { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the m (HTTP method) claim is validated - request
+ /// binding. Default true; disabling it lets a captured SHR be replayed against another method.
+ ///
+ [DefaultValue(true)]
+ public bool ValidateM { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the u (host + scheme) claim is validated - request
+ /// binding. Default true; disabling it lets a captured SHR be replayed against another host.
+ ///
+ [DefaultValue(true)]
+ public bool ValidateU { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the p (path) claim is validated - request binding.
+ /// Default true; disabling it lets a captured SHR be replayed against another path.
+ ///
+ [DefaultValue(true)]
+ public bool ValidateP { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the q (query parameters) claim is validated -
+ /// request binding. Default false (matches MISE); enabling it hardens binding but the caller
+ /// must sign q or every request fails.
+ ///
+ [DefaultValue(false)]
+ public bool ValidateQ { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the h (headers) claim is validated - request
+ /// binding. Default false (matches MISE); enabling it hardens binding but the caller must sign
+ /// h or every request fails.
+ ///
+ [DefaultValue(false)]
+ public bool ValidateH { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether headers not covered by the signature are accepted.
+ /// Default true (matches MISE).
+ ///
+ [DefaultValue(true)]
+ public bool AcceptUnsignedHeaders { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether query parameters not covered by the signature are
+ /// accepted. Default true (matches MISE).
+ ///
+ [DefaultValue(true)]
+ public bool AcceptUnsignedQueryParameters { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the claims listed in
+ /// are validated when present (even if their individual flag is off). Default false (matches
+ /// MISE); while false the list is inert.
+ ///
+ [DefaultValue(false)]
+ public bool ValidatePresentClaims { get; set; }
+
+ ///
+ /// Gets or sets the signed HTTP request lifetime - the ts clock-skew tolerance. Default
+ /// 5 minutes (matches MISE).
+ ///
+ public TimeSpan SignedHttpRequestLifetime { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Gets or sets the claims validated only when present, gated by .
+ /// Default { "m", "p" } (matches MISE). Inert unless is
+ /// true.
+ ///
+ public IList ClaimsToValidateWhenPresent { get; set; } = new Collection { "m", "p" };
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
index dd9cda737..71037bb27 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
@@ -23,6 +23,13 @@ public class SidecarOptions
/// from downstream APIs. Defaults to false.
///
public bool AllowOutboundRedirects { get; set; }
+
+ ///
+ /// SPIKE (throwaway): operator-tunable inbound SHR PoP validation flags, bound from the
+ /// Sidecar:PopValidation subsection. When the subsection is absent every flag keeps its
+ /// strict MISE default, so PoP validation behaves exactly as the original hard-coded PoC.
+ ///
+ public PopValidationOptions PopValidation { get; set; } = new();
}
///
diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
index b182f48a3..5a1a0ba42 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
@@ -9,6 +9,7 @@
using Microsoft.Identity.Web.Resource;
using Microsoft.Identity.Web.Sidecar.Logging;
using Microsoft.Identity.Web.Sidecar.Models;
+using Microsoft.Identity.Web.Sidecar.Pop;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Microsoft.Identity.Web.Sidecar.Endpoints;
@@ -19,7 +20,7 @@ public static void AddValidateRequestEndpoints(this WebApplication app)
{
app.MapGet("/Validate", ValidateEndpoint).
WithName("ValidateAuthorizationHeader").
- RequireAuthorization().
+ RequireAuthorization(PopConstants.ValidatePolicyName).
ProducesProblem(StatusCodes.Status400BadRequest).
ProducesProblem(StatusCodes.Status401Unauthorized);
}
@@ -29,6 +30,15 @@ private static Results, ProblemHttpResult>
HttpContext httpContext,
[FromServices] IConfiguration configuration)
{
+ // SPIKE (throwaway): inbound SHR PoP branch. The PoP authentication handler stashes the
+ // validated inner (app-only) access token here after re-hosted MISE SHR validation succeeds.
+ // App-only tokens carry roles, not scopes, so the scope check below is skipped for PoP - the
+ // same effective outcome as an app-only Bearer token under AllowWebApiToBeAuthorizedByACL.
+ if (httpContext.Items[PopConstants.ValidatedAccessTokenItemKey] is JsonWebToken popToken)
+ {
+ return BuildResult(logger, PopConstants.ProtocolName, popToken);
+ }
+
string scopeRequiredByApi = configuration["AzureAd:Scopes"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(scopeRequiredByApi))
{
@@ -42,6 +52,14 @@ private static Results, ProblemHttpResult>
return TypedResults.Problem("No token found", statusCode: StatusCodes.Status400BadRequest);
}
+ return BuildResult(logger, "Bearer", token);
+ }
+
+ private static Results, ProblemHttpResult> BuildResult(
+ ILogger logger,
+ string protocol,
+ JsonWebToken token)
+ {
var decodedBody = Base64Url.DecodeFromChars(token.EncodedPayload);
JsonNode? jsonDoc;
@@ -62,7 +80,7 @@ private static Results, ProblemHttpResult>
}
var result = new ValidateAuthorizationHeaderResult(
- Protocol: "Bearer",
+ Protocol: protocol,
Token: token.EncodedToken,
Claims: jsonDoc
);
diff --git a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
index 167c1e6bb..ed9c1b496 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
@@ -39,4 +39,14 @@ public static partial class LoggerMessageExtensions
Message = "Caller-supplied agent identity parameters were ignored on route '{RouteName}' because overrides are not allowed for it by configuration. To enable agent identity overrides, set 'Sidecar:AllowOverrides:{RouteName}' to true.",
EventName = "AgentIdentityOverridesIgnored")]
public static partial void AgentIdentityOverridesIgnored(this ILogger logger, string routeName);
+
+ // Mirrors JwtBearer's TokenValidationFailed (also LogLevel.Information): record the reason an
+ // inbound SHR PoP request failed validation server-side without disclosing it to the caller. The
+ // reason is a Wilson validation message (e.g. lifetime/signature/audience) and never the token.
+ [LoggerMessage(
+ EventId = 6,
+ Level = LogLevel.Information,
+ Message = "Inbound SHR PoP validation failed: {Reason}",
+ EventName = "Pop_ValidationFailed")]
+ public static partial void PopValidationFailed(this ILogger logger, string reason);
}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj
index bc5b2ef2f..51627780b 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj
+++ b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj
@@ -31,6 +31,8 @@
+
+
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
new file mode 100644
index 000000000..ed55f9b58
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Identity.Web.Sidecar.Pop;
+
+///
+/// SPIKE (throwaway): wires inbound SHR PoP into the sidecar's authentication pipeline WITHOUT
+/// changing the Bearer path or the global default authentication scheme.
+///
+/// It registers a second authentication handler under the "PoP" scheme and a named authorization
+/// policy () that accepts BOTH "Bearer" and "PoP". Only
+/// the /Validate endpoint opts into that policy, so every other endpoint - and the default
+/// scheme - remains exactly as before. For a Bearer request the PoP handler no-ops (wrong scheme) and
+/// JwtBearer validates as usual; for a PoP request the JwtBearer handler no-ops and the PoP handler
+/// runs the re-hosted MISE SHR validation.
+///
+internal static class PopAuthenticationExtensions
+{
+ public static AuthenticationBuilder AddInboundShrPop(this AuthenticationBuilder builder)
+ {
+ builder.Services.AddSingleton();
+
+ builder.AddScheme(
+ PopConstants.SchemeName,
+ static _ => { });
+
+ // Scope PoP to /Validate via a policy that authenticates Bearer OR PoP. The global default
+ // authentication scheme is intentionally left as "Bearer" so nothing else changes.
+ builder.Services
+ .AddAuthorizationBuilder()
+ .AddPolicy(PopConstants.ValidatePolicyName, policy =>
+ {
+ policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, PopConstants.SchemeName);
+ policy.RequireAuthenticatedUser();
+ });
+
+ return builder;
+ }
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs
new file mode 100644
index 000000000..c91c5c2d3
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs
@@ -0,0 +1,104 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Net.Http.Headers;
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Web.Sidecar.Logging;
+
+namespace Microsoft.Identity.Web.Sidecar.Pop;
+
+/// SPIKE (throwaway): options for the PoP authentication scheme.
+internal sealed class PopAuthenticationSchemeOptions : AuthenticationSchemeOptions
+{
+}
+
+///
+/// SPIKE (throwaway): ASP.NET Core authentication handler for inbound SHR PoP. This is the
+/// sidecar-native equivalent of MISE's PoP protocol parser + validation rule: it recognises the
+/// "PoP" Authorization scheme, runs , and on success produces a
+/// from the validated inner access token. The existing Bearer
+/// (JwtBearer) handler is untouched; the /Validate endpoint opts into a
+/// multi-scheme authorization policy that accepts both Bearer and PoP.
+///
+internal sealed class PopAuthenticationHandler : AuthenticationHandler
+{
+ private readonly ShrPopValidationService _validationService;
+
+ public PopAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ShrPopValidationService validationService)
+ : base(options, logger, encoder)
+ {
+ _validationService = validationService;
+ }
+
+ protected override async Task HandleAuthenticateAsync()
+ {
+ string authorization = Request.Headers.Authorization.ToString();
+ if (string.IsNullOrEmpty(authorization))
+ {
+ return AuthenticateResult.NoResult();
+ }
+
+ if (!AuthenticationHeaderValue.TryParse(authorization, out AuthenticationHeaderValue? headerValue) ||
+ !string.Equals(headerValue.Scheme, PopConstants.SchemeName, StringComparison.OrdinalIgnoreCase) ||
+ string.IsNullOrEmpty(headerValue.Parameter))
+ {
+ return AuthenticateResult.NoResult();
+ }
+
+ ShrPopValidationResult result = await _validationService
+ .ValidateAsync(headerValue.Parameter, Request, Context.RequestAborted)
+ .ConfigureAwait(false);
+
+ if (!result.Succeeded)
+ {
+ string error = result.Error ?? "PoP validation failed.";
+
+ // Record the reason server-side (parity with JwtBearer's TokenValidationFailed) so the
+ // failure is diagnosable from logs while the challenge response stays generic.
+ Logger.PopValidationFailed(error);
+ return AuthenticateResult.Fail(error);
+ }
+
+ // Expose the validated inner access token to the /Validate endpoint seam.
+ Context.Items[PopConstants.ValidatedAccessTokenItemKey] = result.ValidatedAccessToken;
+
+ var principal = new ClaimsPrincipal(result.ClaimsIdentity!);
+ var ticket = new AuthenticationTicket(principal, Scheme.Name);
+ return AuthenticateResult.Success(ticket);
+ }
+
+ // SPIKE (throwaway): mirror JwtBearer's failure surface for the PoP scheme. Stock JwtBearer
+ // answers an auth failure with 401 + "WWW-Authenticate: Bearer error=...". The /Validate policy
+ // lists BOTH schemes, so on failure ASP.NET Core challenges each: the JwtBearer handler emits its
+ // Bearer challenge (unchanged), and this override emits the matching "WWW-Authenticate: PoP" so a
+ // PoP caller is told PoP is the scheme in play instead of only (misleadingly) Bearer. The header
+ // is appended (never overwritten), and only when the caller actually used the PoP scheme - so a
+ // plain Bearer or no-credential 401 still advertises Bearer alone, exactly as before.
+ //
+ // We deliberately emit only error="invalid_token" and no verbose error_description: unlike
+ // Bearer's IncludeErrorDetails default, we avoid echoing internal SHR validation text into a
+ // response header (matching MISE's DPoP response-header creator, which keeps error_description
+ // generic). The precise reason is available in the server-side log; the description is an easy
+ // future knob, and the nonce challenge params (nonce=/error="use_dpop_nonce") are additive later.
+ protected override Task HandleChallengeAsync(AuthenticationProperties properties)
+ {
+ if (AuthenticationHeaderValue.TryParse(Request.Headers.Authorization.ToString(), out AuthenticationHeaderValue? headerValue) &&
+ string.Equals(headerValue.Scheme, PopConstants.SchemeName, StringComparison.OrdinalIgnoreCase))
+ {
+ Response.StatusCode = StatusCodes.Status401Unauthorized;
+ Response.Headers.Append(
+ Microsoft.Net.Http.Headers.HeaderNames.WWWAuthenticate,
+ $"{PopConstants.SchemeName} error=\"invalid_token\"");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
new file mode 100644
index 000000000..9014d9d77
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.IdentityModel.Protocols.SignedHttpRequest;
+
+namespace Microsoft.Identity.Web.Sidecar.Pop;
+
+///
+/// SPIKE (throwaway): constants for the inbound Signed HTTP Request (SHR) Proof-of-Possession path.
+/// The "PoP" scheme token and the request-line header names mirror the MISE container contract
+/// (ContainerShared/RequestHeaderNames.cs) so a future shared Envoy front-end stays wire-compatible.
+///
+internal static class PopConstants
+{
+ /// The Authorization scheme token for SHR PoP ("PoP"). Sourced from Wilson so it never drifts.
+ public const string SchemeName = SignedHttpRequestConstants.AuthorizationHeaderSchemeName;
+
+ /// Protocol label returned to the caller for a validated PoP request.
+ public const string ProtocolName = "PoP";
+
+ ///
+ /// Name of the authorization policy applied to the /Validate endpoint. The policy accepts
+ /// BOTH the "Bearer" and "PoP" authentication schemes, so a single endpoint transparently handles
+ /// either credential without changing the global default scheme (which stays "Bearer"). This keeps
+ /// the change scoped to /Validate; every other endpoint is completely unaffected.
+ ///
+ public const string ValidatePolicyName = "SidecarValidateBearerOrPoP";
+
+ // --- Request-line contract headers (identical to MISE ContainerShared/RequestHeaderNames.cs) ---
+
+ /// Header carrying the original (signed) request absolute URI.
+ public const string OriginalUriHeaderName = "original-uri";
+
+ /// Header carrying the original (signed) request HTTP method.
+ public const string OriginalMethodHeaderName = "original-method";
+
+ /// Header carrying the original client IP (reserved; not used by m/u/p/ts validation).
+ public const string OriginalIpAddressHeaderName = "x-forwarded-for";
+
+ /// HttpContext.Items key holding the validated inner access token (a JsonWebToken).
+ public const string ValidatedAccessTokenItemKey = "Sidecar.Pop.ValidatedAccessToken";
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
new file mode 100644
index 000000000..263aed2fe
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.IdentityModel.Protocols;
+
+namespace Microsoft.Identity.Web.Sidecar.Pop;
+
+///
+/// SPIKE (throwaway): builds the Wilson that SHR validation binds the
+/// signature against, from the request-line contract headers (original-uri / original-method).
+///
+///
+/// Design decision (flagged for the design doc): the sidecar is app-invoked today (not Envoy-fronted).
+/// The call to /Validate is NOT the request the SHR was signed over, so the calling app MUST
+/// supply the original method + URI - there is no safe server-side derivation. Hence these headers are
+/// REQUIRED for PoP, a deliberate deviation from MISE (which can fall back to the Envoy-provided
+/// Host/method). The header names are kept identical to MISE so a later Envoy front-end and the MISE
+/// container remain consistent. Only Uri + Method are needed because v1 validates m/u/p/ts (h and q are
+/// OFF), so request headers, query string and body do not participate in the signature.
+///
+internal static class PopHttpRequestFactory
+{
+ public static bool TryCreate(HttpRequest request, out HttpRequestData requestData, out string? error)
+ {
+ requestData = null!;
+
+ string uri = request.Headers[PopConstants.OriginalUriHeaderName].ToString();
+ if (string.IsNullOrEmpty(uri))
+ {
+ error = $"Missing required '{PopConstants.OriginalUriHeaderName}' header for PoP validation.";
+ return false;
+ }
+
+ string method = request.Headers[PopConstants.OriginalMethodHeaderName].ToString();
+ if (string.IsNullOrEmpty(method))
+ {
+ error = $"Missing required '{PopConstants.OriginalMethodHeaderName}' header for PoP validation.";
+ return false;
+ }
+
+ if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri))
+ {
+ error = $"The '{PopConstants.OriginalUriHeaderName}' header must be an absolute URI.";
+ return false;
+ }
+
+ requestData = new HttpRequestData
+ {
+ Uri = parsedUri,
+ Method = method,
+ };
+
+ error = null;
+ return true;
+ }
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs
new file mode 100644
index 000000000..afa5885ec
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.Claims;
+using Microsoft.IdentityModel.JsonWebTokens;
+
+namespace Microsoft.Identity.Web.Sidecar.Pop;
+
+/// SPIKE (throwaway): outcome of inbound SHR PoP validation.
+internal sealed class ShrPopValidationResult
+{
+ private ShrPopValidationResult()
+ {
+ }
+
+ public bool Succeeded { get; private init; }
+
+ public string? Error { get; private init; }
+
+ /// The validated inner (embedded) access token, present on success.
+ public JsonWebToken? ValidatedAccessToken { get; private init; }
+
+ /// The claims identity built from the validated inner access token, present on success.
+ public ClaimsIdentity? ClaimsIdentity { get; private init; }
+
+ public static ShrPopValidationResult Fail(string error) =>
+ new() { Succeeded = false, Error = error };
+
+ public static ShrPopValidationResult Success(JsonWebToken validatedAccessToken, ClaimsIdentity claimsIdentity) =>
+ new() { Succeeded = true, ValidatedAccessToken = validatedAccessToken, ClaimsIdentity = claimsIdentity };
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
new file mode 100644
index 000000000..67854c5de
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Web.Sidecar.Configuration;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Protocols.SignedHttpRequest;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.Identity.Web.Sidecar.Pop;
+
+///
+/// SPIKE (throwaway): validates an inbound Signed HTTP Request (SHR) Proof-of-Possession token by
+/// re-hosting MISE.AuthN's -based validation (see MISE
+/// PopTokenHandlerValidationRule + SignedHttpRequestValidationContextFactory).
+/// Scope: app-only tokens, timestamp-only (ts) validation. Server nonce is intentionally NOT wired.
+///
+internal sealed class ShrPopValidationService
+{
+ // SignedHttpRequestHandler is stateless and thread-safe; MISE also holds a single instance.
+ private static readonly SignedHttpRequestHandler s_handler = new();
+
+ private readonly IOptionsMonitor _jwtBearerOptionsMonitor;
+ private readonly IOptionsMonitor _sidecarOptionsMonitor;
+
+ public ShrPopValidationService(
+ IOptionsMonitor jwtBearerOptionsMonitor,
+ IOptionsMonitor sidecarOptionsMonitor)
+ {
+ _jwtBearerOptionsMonitor = jwtBearerOptionsMonitor;
+ _sidecarOptionsMonitor = sidecarOptionsMonitor;
+ }
+
+ ///
+ /// Maps the operator-configurable onto Wilson's
+ /// , mirroring MISE's
+ /// SignedHttpRequestValidationContextFactory.CreateParametersFromOptions. With the options at
+ /// their defaults (no Sidecar:PopValidation section) this reproduces the original hard-coded
+ /// behavior: m/u/p/ts ON; q/h OFF; b OFF; unsigned headers/query accepted; 5-minute lifetime.
+ ///
+ internal static SignedHttpRequestValidationParameters CreateValidationParameters(PopValidationOptions options) => new()
+ {
+ ValidateM = options.ValidateM,
+ ValidateU = options.ValidateU,
+ ValidateP = options.ValidateP,
+ ValidateTs = options.ValidateTs,
+ ValidateH = options.ValidateH,
+ ValidateQ = options.ValidateQ,
+
+ // Body-hash (b) stays OFF and is intentionally NOT operator-configurable: validating it requires
+ // buffering/reading the request body, and MISE never surfaces it. See the design doc deferrals.
+ ValidateB = false,
+
+ AcceptUnsignedHeaders = options.AcceptUnsignedHeaders,
+ AcceptUnsignedQueryParameters = options.AcceptUnsignedQueryParameters,
+ ValidatePresentClaims = options.ValidatePresentClaims,
+ ClaimsToValidateWhenPresent = options.ClaimsToValidateWhenPresent,
+ SignedHttpRequestLifetime = options.SignedHttpRequestLifetime,
+
+ // Server nonce is out of scope (timestamp-only): NonceValidatorAsync stays unset. jku key
+ // resolution is left at Wilson defaults (AllowResolvingPopKeyFromJku = false) - no egress.
+ };
+
+ public async Task ValidateAsync(
+ string encodedSignedHttpRequest,
+ HttpRequest request,
+ CancellationToken cancellationToken)
+ {
+ // (1) Request-line contract -> Wilson HttpRequestData (binds m/u/p).
+ if (!PopHttpRequestFactory.TryCreate(request, out var requestData, out var contractError))
+ {
+ return ShrPopValidationResult.Fail(contractError!);
+ }
+
+ // (3) Reuse the SAME TokenValidationParameters JwtBearer built from AzureAd config - one source
+ // of truth for issuer/audience/signing-keys. Clone so the shared instance is never mutated
+ // per-request (mirrors MISE PopTokenTypeInfo.GetClonedValidationParameters).
+ JwtBearerOptions bearerOptions =
+ _jwtBearerOptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme);
+ TokenValidationParameters? bearerTvp = bearerOptions.TokenValidationParameters;
+ if (bearerTvp is null)
+ {
+ return ShrPopValidationResult.Fail("Bearer TokenValidationParameters are not configured.");
+ }
+
+ TokenValidationParameters accessTokenValidationParameters = bearerTvp.Clone();
+
+ // Ensure the cloned TVP can resolve the embedded AT's signing keys via live JWKS. In production
+ // Microsoft.Identity.Web copies options.ConfigurationManager onto the shared TVP lazily, inside
+ // the JwtBearer handler's OnMessageReceived event (IdentityOptionsHelpers.InitializeJwtBearerEvents),
+ // which only fires when the JwtBearer scheme is authenticated first. Copying it explicitly here
+ // removes that ordering/timing dependency so inner-AT signature validation always has a key source.
+ accessTokenValidationParameters.ConfigurationManager ??=
+ bearerOptions.ConfigurationManager as BaseConfigurationManager;
+
+ var validationContext = new SignedHttpRequestValidationContext(
+ encodedSignedHttpRequest,
+ requestData,
+ accessTokenValidationParameters,
+ CreateValidationParameters(_sidecarOptionsMonitor.CurrentValue.PopValidation));
+
+ SignedHttpRequestValidationResult result;
+ try
+ {
+ result = await s_handler
+ .ValidateSignedHttpRequestAsync(validationContext, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Wilson throws for malformed SHR/JWT input; treat as a validation failure.
+ return ShrPopValidationResult.Fail($"SHR validation threw: {ex.Message}");
+ }
+
+ if (!result.IsValid)
+ {
+ return ShrPopValidationResult.Fail(result.Exception?.Message ?? "SHR validation failed.");
+ }
+
+ // The SHR handler validated BOTH the outer PoP signature AND the inner access token via the
+ // reused TVP (issuer/audience/expiry/signing-key all enforced on the embedded AT here).
+ TokenValidationResult accessTokenResult = result.AccessTokenValidationResult;
+ if (accessTokenResult is null ||
+ !accessTokenResult.IsValid ||
+ accessTokenResult.SecurityToken is not JsonWebToken validatedAccessToken ||
+ accessTokenResult.ClaimsIdentity is null)
+ {
+ return ShrPopValidationResult.Fail("Embedded access token validation failed.");
+ }
+
+ return ShrPopValidationResult.Success(validatedAccessToken, accessTokenResult.ClaimsIdentity);
+ }
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs
index 7fad0e589..695f5c0ed 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Program.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web.Sidecar.Configuration;
using Microsoft.Identity.Web.Sidecar.Endpoints;
+using Microsoft.Identity.Web.Sidecar.Pop;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Microsoft.Identity.Web.Sidecar;
@@ -30,12 +31,20 @@ public static void Main(string[] args)
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
- builder.Services
- .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ // SPIKE (throwaway): the Bearer wiring below is UNCHANGED (still the default scheme).
+ // AddInboundShrPop additively registers a second "PoP" authentication handler plus a named
+ // authorization policy that only the /Validate endpoint opts into, so no other endpoint and
+ // no global default is affected.
+ var authenticationBuilder = builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
+
+ authenticationBuilder
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"), subscribeToJwtBearerMiddlewareDiagnosticsEvents: true)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddInMemoryTokenCaches();
+ authenticationBuilder.AddInboundShrPop();
+
builder.Services.PostConfigure(options =>
{
options.AllowWebApiToBeAuthorizedByACL = true;
diff --git a/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs b/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs
new file mode 100644
index 000000000..a2f0f0618
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Runtime.CompilerServices;
+
+// SPIKE (throwaway): expose internal SHR PoP mapping helpers (e.g. ShrPopValidationService's
+// DTO -> SignedHttpRequestValidationParameters mapping) to the sidecar test project for unit testing.
+[assembly: InternalsVisibleTo("Sidecar.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")]
diff --git a/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs b/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs
new file mode 100644
index 000000000..b715436ec
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs
@@ -0,0 +1,205 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Sidecar.Tests;
+
+///
+/// SPIKE (throwaway): a self-contained mock Identity Provider (Entra/ESTS stand-in) hosted in-process
+/// on the ASP.NET Core . It reproduces the real-world validation surface so the
+/// sidecar validates a PoP token's embedded access token through its GENUINE JwtBearer pipeline -
+/// live OIDC discovery + JWKS fetch + signature/issuer/audience/lifetime checks - instead of a
+/// statically injected signing key.
+///
+/// Endpoints:
+/// - GET /{tenant}/v2.0/.well-known/openid-configuration : OIDC discovery (issuer + jwks_uri).
+/// - GET /keys : JWKS publishing the public half of
+/// .
+/// - POST /{tenant}/oauth2/v2.0/token : client-credentials-style endpoint that
+/// accepts a req_cnf (the caller's PoP
+/// public key) and returns an app-only access
+/// token whose cnf binds that key.
+///
+/// The signing key it publishes is the same key it signs tokens with, so tokens it issues verify
+/// against the discovery document exactly as a real Entra-issued token would.
+///
+internal sealed class MockIdpServer : IDisposable
+{
+ // The tenant + issuer mirror a real Entra v2 token. Only the key-fetch (jwks_uri) and discovery are
+ // redirected to this in-process server; the issuer string stays AAD-shaped so tokens look real.
+ public const string Tenant = "10c419d4-4a50-45b2-aa4e-919fb84df24f";
+
+ // Host label is irrelevant to TestServer routing (it matches by path), but must be an absolute URL.
+ private const string BaseAddress = "https://mock-idp.localhost";
+
+ public static string Issuer => PopTestCrypto.TestIssuer;
+ public static string MetadataAddress => $"{BaseAddress}/{Tenant}/v2.0/.well-known/openid-configuration";
+ public static string JwksUri => $"{BaseAddress}/keys";
+ public static string TokenEndpoint => $"{BaseAddress}/{Tenant}/oauth2/v2.0/token";
+
+ private readonly IHost _host;
+ private readonly TestServer _server;
+
+ public MockIdpServer()
+ {
+ string discoveryJson = BuildDiscoveryDocument();
+ string jwksJson = BuildJwks();
+
+ _host = new HostBuilder()
+ .ConfigureWebHost(web =>
+ {
+ web.UseTestServer();
+ web.ConfigureServices(services => services.AddRouting());
+ web.Configure(app =>
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet(
+ "/{tenant}/v2.0/.well-known/openid-configuration",
+ async context => await WriteJsonAsync(context, discoveryJson));
+
+ endpoints.MapGet(
+ "/keys",
+ async context => await WriteJsonAsync(context, jwksJson));
+
+ endpoints.MapPost(
+ "/{tenant}/oauth2/v2.0/token",
+ IssueTokenAsync);
+ });
+ });
+ })
+ .Build();
+
+ _host.Start();
+ _server = _host.GetTestServer();
+ }
+
+ /// The in-memory message handler the sidecar's JwtBearer backchannel talks to.
+ public HttpMessageHandler Handler => _server.CreateHandler();
+
+ /// An bound to this mock IdP (used as the JwtBearer backchannel).
+ public HttpClient CreateBackchannel() => _server.CreateClient();
+
+ ///
+ /// A configuration manager that fetches this mock IdP's discovery + JWKS over the in-memory handler.
+ /// Assigned to the Bearer so BOTH the
+ /// JwtBearer handler AND the re-hosted SHR validator resolve signing keys live from JWKS.
+ ///
+ public ConfigurationManager CreateConfigurationManager()
+ {
+ var documentRetriever = new HttpDocumentRetriever(CreateBackchannel()) { RequireHttps = false };
+ return new ConfigurationManager(
+ MetadataAddress,
+ new OpenIdConnectConfigurationRetriever(),
+ documentRetriever);
+ }
+
+ ///
+ /// Performs a client-credentials-style token request against the mock IdP's token endpoint over HTTP,
+ /// presenting as the PoP key to bind. Returns the raw access token.
+ ///
+ public async Task AcquireAppOnlyTokenAsync(string reqCnf)
+ {
+ using var client = _server.CreateClient();
+ using var content = new FormUrlEncodedContent(new[]
+ {
+ new KeyValuePair("grant_type", "client_credentials"),
+ new KeyValuePair("scope", $"{PopTestCrypto.TestAudience}/.default"),
+ new KeyValuePair("req_cnf", reqCnf),
+ });
+
+ using HttpResponseMessage response = await client.PostAsync(TokenEndpoint, content);
+ response.EnsureSuccessStatusCode();
+
+ string body = await response.Content.ReadAsStringAsync();
+ using JsonDocument document = JsonDocument.Parse(body);
+ return document.RootElement.GetProperty("access_token").GetString()!;
+ }
+
+ private static async Task IssueTokenAsync(HttpContext context)
+ {
+ string reqCnf = context.Request.HasFormContentType
+ ? context.Request.Form["req_cnf"].ToString()
+ : context.Request.Query["req_cnf"].ToString();
+
+ JsonElement cnf;
+ if (!string.IsNullOrEmpty(reqCnf))
+ {
+ // req_cnf is base64url(JSON JWK) supplied by the caller: bind the token to that key.
+ string jwkJson = Base64UrlEncoder.Decode(reqCnf);
+ using JsonDocument jwkDocument = JsonDocument.Parse(jwkJson);
+ cnf = PopTestCrypto.WrapJwkAsCnf(jwkDocument.RootElement);
+ }
+ else
+ {
+ // No req_cnf: default-bind to the shared test PoP key.
+ cnf = PopTestCrypto.BuildCnf(PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ }
+
+ string accessToken = PopTestCrypto.CreateAccessTokenWithCnf(
+ Issuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10), cnf);
+
+ var payload = new Dictionary
+ {
+ ["token_type"] = "PoP",
+ ["expires_in"] = 600,
+ ["access_token"] = accessToken,
+ };
+
+ await WriteJsonAsync(context, JsonSerializer.Serialize(payload));
+ }
+
+ private static string BuildDiscoveryDocument()
+ {
+ var document = new Dictionary
+ {
+ ["issuer"] = Issuer,
+ ["jwks_uri"] = JwksUri,
+ ["authorization_endpoint"] = $"{BaseAddress}/{Tenant}/oauth2/v2.0/authorize",
+ ["token_endpoint"] = TokenEndpoint,
+ ["response_types_supported"] = new[] { "token", "id_token" },
+ ["subject_types_supported"] = new[] { "pairwise" },
+ ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
+ };
+
+ return JsonSerializer.Serialize(document);
+ }
+
+ private static string BuildJwks()
+ {
+ Dictionary jwk = PopTestCrypto.BuildJwkDict(
+ PopTestCrypto.AccessTokenSigningKey, PopTestCrypto.AccessTokenKeyId);
+ jwk["use"] = "sig";
+ jwk["alg"] = "RS256";
+
+ var jwks = new Dictionary { ["keys"] = new[] { jwk } };
+ return JsonSerializer.Serialize(jwks);
+ }
+
+ private static async Task WriteJsonAsync(HttpContext context, string json)
+ {
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(json);
+ }
+
+ public void Dispose()
+ {
+ _server.Dispose();
+ _host.Dispose();
+ }
+}
diff --git a/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs b/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs
index 580eb2be4..28323cc4d 100644
--- a/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs
@@ -148,8 +148,11 @@ public async Task Sidecar_DownstreamCalls_UseDefaultHttpClientFactoryPath()
var options = provider.GetRequiredService>().Get(apiName);
// Assert
- Assert.All(schemes, s => Assert.Equal(JwtBearerDefaults.AuthenticationScheme, s.Name));
+ // Inbound SHR PoP additively registers a second "PoP" scheme; Bearer must remain registered
+ // and stay the default authenticate scheme, and no mTLS scheme should be present.
+ Assert.Contains(schemes, s => s.Name == JwtBearerDefaults.AuthenticationScheme);
Assert.Equal(JwtBearerDefaults.AuthenticationScheme, defaultScheme?.Name);
+ Assert.DoesNotContain(schemes, s => s.Name.Contains("MTLS", StringComparison.OrdinalIgnoreCase));
Assert.NotEqual("MTLS", options.ProtocolScheme, StringComparer.OrdinalIgnoreCase);
Assert.NotEqual("MTLS_POP", options.ProtocolScheme, StringComparer.OrdinalIgnoreCase);
}
diff --git a/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs b/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs
new file mode 100644
index 000000000..11efd6259
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Sidecar.Tests;
+
+///
+/// SPIKE (throwaway): a sidecar test host that repoints the "Bearer" JwtBearerOptions at an in-process
+/// (Entra/ESTS stand-in). The embedded access token inside an SHR is then
+/// validated through the sidecar's GENUINE JwtBearer pipeline - live OIDC discovery + JWKS fetch +
+/// signature/issuer/audience/lifetime - with no statically injected signing key. This exercises the
+/// real-world validation path while proving objective 3: the PoP path reuses the SAME "Bearer" TVP
+/// (issuer/audience/expiry/signing key).
+///
+public class PopSidecarApiFactory : SidecarApiFactory
+{
+ private readonly MockIdpServer _mockIdp = new();
+
+ /// The in-process mock IdP, exposed so a test can perform a full token acquisition round-trip.
+ internal MockIdpServer Idp => _mockIdp;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+
+ builder.ConfigureServices(services =>
+ {
+ // Runs after Microsoft.Identity.Web has built the online TVP, so this override wins.
+ services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, options =>
+ {
+ ConfigurationManager configurationManager =
+ _mockIdp.CreateConfigurationManager();
+
+ // Point JwtBearer's metadata/backchannel at the mock IdP (loopback, plain HTTP).
+ options.Authority = null;
+ options.MetadataAddress = MockIdpServer.MetadataAddress;
+ options.RequireHttpsMetadata = false;
+ options.Backchannel = _mockIdp.CreateBackchannel();
+ options.ConfigurationManager = configurationManager;
+
+ // Keep the AzureAd-shaped issuer/audience/lifetime checks, but resolve signing keys from
+ // the mock's live JWKS via ConfigurationManager (assigned to the TVP so the re-hosted SHR
+ // validator - which only sees the TVP - fetches keys the same way). Clear the AAD alias
+ // issuer validator (meaningless against a mock tenant) and validate the issuer string.
+ TokenValidationParameters parameters = options.TokenValidationParameters;
+ parameters.ValidateIssuer = true;
+ parameters.ValidIssuer = MockIdpServer.Issuer;
+ parameters.IssuerValidator = null;
+ parameters.ValidateAudience = true;
+ parameters.ValidAudience = PopTestCrypto.TestAudience;
+ parameters.ValidateIssuerSigningKey = true;
+ parameters.ValidateLifetime = true;
+ // PRODUCTION-FAITHFUL: set ConfigurationManager on the OPTIONS only (as stock
+ // JwtBearerPostConfigureOptions does from Authority). We deliberately DO NOT assign it
+ // onto options.TokenValidationParameters here, so the test proves the embedded-AT key
+ // source reaches the re-hosted SHR validator through the real pipeline, not a shortcut.
+ parameters.ConfigurationManager = null;
+ parameters.IssuerSigningKey = null;
+ parameters.IssuerSigningKeys = null;
+ parameters.RoleClaimType = "roles";
+ parameters.NameClaimType = "sub";
+ });
+ });
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ {
+ _mockIdp.Dispose();
+ }
+ }
+}
diff --git a/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs b/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
new file mode 100644
index 000000000..5ab47695a
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
@@ -0,0 +1,213 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.SignedHttpRequest;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Sidecar.Tests;
+
+///
+/// SPIKE (throwaway) test helper: mints a self-contained ARM-shaped Signed HTTP Request (SHR) PoP
+/// token without any live AAD dependency.
+///
+/// Two RSA keys are used, mirroring a real PoP flow:
+/// - (key A) signs the embedded (app-only) access token. Its
+/// public half is trusted by the overridden Bearer TokenValidationParameters (see
+/// ), proving the inner AT is validated via the reused AzureAd TVP.
+/// - (key B) signs the outer SHR and is advertised in the access token's
+/// cnf.jwk claim so the SHR handler can bind the two together.
+///
+internal static class PopTestCrypto
+{
+ public const string TestIssuer = "https://sts.windows.net/10c419d4-4a50-45b2-aa4e-919fb84df24f/";
+ public const string TestAudience = "aab5089d-e764-47e3-9f28-cc11c2513821";
+ public const string AccessTokenKeyId = "test-at-key";
+ public const string PopKeyId = "test-pop-key";
+
+ // Cached immutable key material. RSA instances are NOT thread-safe and xUnit runs test classes in
+ // parallel, so we never share an instance: each accessor below hands out a FRESH RSA created from
+ // these cached parameters, used only on the calling thread.
+ private static readonly RSAParameters s_accessTokenPrivate;
+ private static readonly RSAParameters s_popPrivate;
+ private static readonly RSAParameters s_untrustedPrivate;
+
+ static PopTestCrypto()
+ {
+ using var accessTokenKey = RSA.Create(2048);
+ s_accessTokenPrivate = accessTokenKey.ExportParameters(true);
+
+ using var popKey = RSA.Create(2048);
+ s_popPrivate = popKey.ExportParameters(true);
+
+ using var untrustedKey = RSA.Create(2048);
+ s_untrustedPrivate = untrustedKey.ExportParameters(true);
+ }
+
+ /// Signs the embedded (app-only) access token; its public half is published via mock JWKS.
+ public static RSA AccessTokenSigningKey => RSA.Create(s_accessTokenPrivate);
+
+ /// Signs the outer SHR and is advertised in the access token's cnf.jwk claim.
+ public static RSA PopSigningKey => RSA.Create(s_popPrivate);
+
+ /// A key that is neither published via JWKS nor advertised in cnf - used for negative tests.
+ public static RSA UntrustedKey => RSA.Create(s_untrustedPrivate);
+
+ /// The public signing key the Bearer TVP must trust to validate the embedded access token.
+ public static RsaSecurityKey AccessTokenPublicKey =>
+ new(s_accessTokenPrivate) { KeyId = AccessTokenKeyId };
+
+ ///
+ /// Mints an app-only access token bound (via cnf.jwk) to .
+ ///
+ public static string CreateAccessToken(
+ string issuer,
+ string audience,
+ DateTime expires,
+ RSA cnfKey,
+ string cnfKeyId) =>
+ CreateAccessTokenWithCnf(issuer, audience, expires, BuildCnf(cnfKey, cnfKeyId));
+
+ ///
+ /// Mints an app-only access token carrying the supplied cnf confirmation claim (or none when
+ /// is null). Signed by , whose public half
+ /// the mock IdP publishes via JWKS - so the sidecar validates this token through the live
+ /// discovery/JWKS path, not a statically injected key.
+ ///
+ public static string CreateAccessTokenWithCnf(
+ string issuer,
+ string audience,
+ DateTime expires,
+ JsonElement? cnf)
+ {
+ var handler = new JsonWebTokenHandler();
+ DateTime now = DateTime.UtcNow;
+
+ var claims = new Dictionary
+ {
+ ["sub"] = "11111111-1111-1111-1111-111111111111",
+ ["oid"] = "11111111-1111-1111-1111-111111111111",
+ ["appid"] = audience,
+ ["idtyp"] = "app",
+ ["roles"] = new[] { "Sidecar.Access" },
+ };
+
+ if (cnf is not null)
+ {
+ claims["cnf"] = cnf.Value;
+ }
+
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = issuer,
+ Audience = audience,
+ IssuedAt = now,
+ NotBefore = now.AddMinutes(-5),
+ Expires = expires,
+ SigningCredentials = new SigningCredentials(
+ new RsaSecurityKey(AccessTokenSigningKey) { KeyId = AccessTokenKeyId },
+ SecurityAlgorithms.RsaSha256),
+ Claims = claims,
+ };
+
+ return handler.CreateToken(descriptor);
+ }
+
+ ///
+ /// Mints an app-only access token with NO cnf claim - used to prove PoP binding is required
+ /// (SHR validation must reject a token that advertises no confirmation key to bind against).
+ ///
+ public static string CreateAccessTokenWithoutCnf(string issuer, string audience, DateTime expires) =>
+ CreateAccessTokenWithCnf(issuer, audience, expires, cnf: null);
+
+ ///
+ /// Creates an SHR over the given method + URI, signed by .
+ /// When is supplied, that exact (signed) ts is embedded
+ /// instead of the current time - used to deterministically produce an expired-ts token.
+ ///
+ public static string CreateSignedHttpRequest(
+ string accessToken,
+ string method,
+ string uri,
+ RSA popSigningKey,
+ string popKeyId,
+ long? tsOverrideEpochSeconds = null)
+ {
+ var handler = new SignedHttpRequestHandler();
+
+ var httpRequestData = new HttpRequestData
+ {
+ Method = method,
+ Uri = new Uri(uri, UriKind.Absolute),
+ };
+
+ var creationParameters = new SignedHttpRequestCreationParameters
+ {
+ CreateM = true,
+ CreateU = true,
+ CreateP = true,
+ CreateTs = tsOverrideEpochSeconds is null,
+ CreateH = false,
+ CreateQ = false,
+ CreateB = false,
+ CreateNonce = false,
+ CreateCnf = false,
+ };
+
+ var descriptor = new SignedHttpRequestDescriptor(
+ accessToken,
+ httpRequestData,
+ new SigningCredentials(new RsaSecurityKey(popSigningKey) { KeyId = popKeyId }, SecurityAlgorithms.RsaSha256),
+ creationParameters);
+
+ if (tsOverrideEpochSeconds is not null)
+ {
+ descriptor.AdditionalPayloadClaims = new Dictionary
+ {
+ ["ts"] = tsOverrideEpochSeconds.Value,
+ };
+ }
+
+ return handler.CreateSignedHttpRequest(descriptor);
+ }
+
+ /// Builds the public RSA JWK dictionary (kty/n/e/kid) for the given key.
+ public static Dictionary BuildJwkDict(RSA rsa, string keyId)
+ {
+ RSAParameters publicParams = rsa.ExportParameters(false);
+ return new Dictionary
+ {
+ ["kty"] = "RSA",
+ ["n"] = Base64UrlEncoder.Encode(publicParams.Modulus!),
+ ["e"] = Base64UrlEncoder.Encode(publicParams.Exponent!),
+ ["kid"] = keyId,
+ };
+ }
+
+ /// Builds a cnf confirmation claim ({"jwk": {...}}) advertising the given key.
+ public static JsonElement BuildCnf(RSA rsa, string keyId) =>
+ ToJsonElement(new Dictionary { ["jwk"] = BuildJwkDict(rsa, keyId) });
+
+ /// Wraps a raw JWK element as a cnf confirmation claim ({"jwk": jwk}).
+ public static JsonElement WrapJwkAsCnf(JsonElement jwk)
+ {
+ using var document = JsonDocument.Parse($"{{\"jwk\":{jwk.GetRawText()}}}");
+ return document.RootElement.Clone();
+ }
+
+ ///
+ /// The client-side req_cnf value (base64url(JSON JWK)) a caller presents to the mock token
+ /// endpoint to request a token bound to its PoP key - mirrors the ESTS PoP acquisition contract.
+ ///
+ public static string BuildReqCnf(RSA rsa, string keyId) =>
+ Base64UrlEncoder.Encode(JsonSerializer.Serialize(BuildJwkDict(rsa, keyId)));
+
+ private static JsonElement ToJsonElement(object value)
+ {
+ using var document = JsonDocument.Parse(JsonSerializer.Serialize(value));
+ return document.RootElement.Clone();
+ }
+}
diff --git a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
new file mode 100644
index 000000000..87fc43554
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Identity.Web.Sidecar.Configuration;
+using Microsoft.Identity.Web.Sidecar.Pop;
+using Microsoft.IdentityModel.Protocols.SignedHttpRequest;
+using Xunit;
+
+namespace Sidecar.Tests;
+
+///
+/// SPIKE (throwaway): unit tests for the operator-configurable SHR PoP validation flags. Verifies that
+/// (a) with no Sidecar:PopValidation config the DTO ->
+/// mapping reproduces the original hard-coded MISE defaults, and (b) a bound config section overrides
+/// exactly the specified flags and nothing else (body-hash b stays OFF, server nonce stays unset).
+///
+public class PopValidationOptionsMappingTests
+{
+ [Fact]
+ public void CreateValidationParameters_WithDefaultOptions_MatchesOriginalHardcodedDefaults()
+ {
+ // Arrange: a brand-new options object is exactly what binding produces when no
+ // Sidecar:PopValidation section is present.
+ var options = new PopValidationOptions();
+
+ // Act
+ SignedHttpRequestValidationParameters parameters =
+ ShrPopValidationService.CreateValidationParameters(options);
+
+ // Assert: byte-for-byte the pre-config behavior (m/u/p/ts ON; q/h/b OFF; accept-unsigned ON;
+ // 5-minute lifetime).
+ Assert.True(parameters.ValidateM);
+ Assert.True(parameters.ValidateU);
+ Assert.True(parameters.ValidateP);
+ Assert.True(parameters.ValidateTs);
+ Assert.False(parameters.ValidateQ);
+ Assert.False(parameters.ValidateH);
+ Assert.False(parameters.ValidateB);
+ Assert.True(parameters.AcceptUnsignedHeaders);
+ Assert.True(parameters.AcceptUnsignedQueryParameters);
+ Assert.Equal(TimeSpan.FromMinutes(5), parameters.SignedHttpRequestLifetime);
+ Assert.False(parameters.ValidatePresentClaims);
+ Assert.Equal(new[] { "m", "p" }, parameters.ClaimsToValidateWhenPresent);
+
+ // Server nonce is out of scope (timestamp-only): the delegate must stay unset, and jku key
+ // resolution must stay off (no outbound egress).
+ Assert.Null(parameters.NonceValidatorAsync);
+ Assert.False(parameters.AllowResolvingPopKeyFromJku);
+ }
+
+ [Fact]
+ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThoseOverrides()
+ {
+ // Arrange: bind a Sidecar:PopValidation subsection that flips a subset of flags, exactly as an
+ // operator would in appsettings.json.
+ IConfiguration configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Sidecar:PopValidation:ValidateQ"] = "true",
+ ["Sidecar:PopValidation:ValidateH"] = "true",
+ ["Sidecar:PopValidation:SignedHttpRequestLifetime"] = "00:02:00",
+ ["Sidecar:PopValidation:AcceptUnsignedHeaders"] = "false",
+ })
+ .Build();
+
+ var sidecarOptions = new SidecarOptions();
+ configuration.GetSection("Sidecar").Bind(sidecarOptions);
+
+ // Act
+ SignedHttpRequestValidationParameters parameters =
+ ShrPopValidationService.CreateValidationParameters(sidecarOptions.PopValidation);
+
+ // Assert: exactly the four overrides took effect...
+ Assert.True(parameters.ValidateQ);
+ Assert.True(parameters.ValidateH);
+ Assert.Equal(TimeSpan.FromMinutes(2), parameters.SignedHttpRequestLifetime);
+ Assert.False(parameters.AcceptUnsignedHeaders);
+
+ // ...and nothing else drifted from the MISE defaults.
+ Assert.True(parameters.ValidateM);
+ Assert.True(parameters.ValidateU);
+ Assert.True(parameters.ValidateP);
+ Assert.True(parameters.ValidateTs);
+ Assert.True(parameters.AcceptUnsignedQueryParameters);
+ Assert.False(parameters.ValidatePresentClaims);
+ Assert.Equal(new[] { "m", "p" }, parameters.ClaimsToValidateWhenPresent);
+
+ // Body-hash (b) is never operator-configurable and must remain OFF, and server nonce stays unset.
+ Assert.False(parameters.ValidateB);
+ Assert.Null(parameters.NonceValidatorAsync);
+ }
+}
diff --git a/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs b/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
new file mode 100644
index 000000000..6effed105
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
@@ -0,0 +1,444 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Web.Sidecar.Models;
+using Xunit;
+
+namespace Sidecar.Tests;
+
+///
+/// SPIKE (throwaway): round-trip + negative tests for inbound SHR PoP validation in the sidecar.
+/// Positive: a valid ARM-shaped SHR (app-only AT, cnf-bound to a test key) returns 200 + Protocol "PoP".
+/// Negatives: tampered method/uri, expired ts, bad signature, and (proving the reused AzureAd TVP)
+/// wrong-issuer / expired embedded access token are all rejected.
+///
+public class ShrPopValidationTests : IClassFixture
+{
+ private const string ArmUri =
+ "https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1?api-version=2023-07-01";
+ private const string ArmMethod = "POST";
+
+ private static readonly JsonSerializerOptions s_webOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly PopSidecarApiFactory _factory;
+
+ public ShrPopValidationTests(PopSidecarApiFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Validate_WithValidShrPop_Returns200_AndProtocolPoPAsync()
+ {
+ // Arrange
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync(s_webOptions);
+ Assert.NotNull(result);
+ Assert.Equal("PoP", result!.Protocol);
+ Assert.Equal(accessToken, result.Token);
+ Assert.NotNull(result.Claims);
+ }
+
+ [Fact]
+ public async Task Validate_FullLoop_TokenAcquiredFromMockIdp_Returns200_AndProtocolPoPAsync()
+ {
+ // Arrange: full real-world round-trip.
+ // 1. The caller presents its PoP public key (req_cnf) to the mock IdP's token endpoint.
+ // 2. The IdP returns an app-only access token whose cnf binds that key, signed by the key it
+ // publishes via JWKS.
+ // 3. The caller mints an SHR over the real request, signed by its PoP private key.
+ // 4. The sidecar validates the embedded token through its live JwtBearer discovery/JWKS path
+ // (no statically injected key), then the PoP signature + m/u/p/ts.
+ string reqCnf = PopTestCrypto.BuildReqCnf(PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string accessToken = await _factory.Idp.AcquireAppOnlyTokenAsync(reqCnf);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync(s_webOptions);
+ Assert.NotNull(result);
+ Assert.Equal("PoP", result!.Protocol);
+ Assert.Equal(accessToken, result.Token);
+ Assert.NotNull(result.Claims);
+ }
+
+ [Fact]
+ public async Task Validate_WithTamperedMethod_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: SHR signs POST, caller claims GET.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, "GET"));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithTamperedUri_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: SHR signs the ARM URI, caller claims a different path.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ const string tamperedUri = "https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/EVIL?api-version=2023-07-01";
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, tamperedUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithExpiredTs_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: embed a signed ts 10 minutes in the past (lifetime is 5 minutes).
+ long expiredTs = DateTimeOffset.UtcNow.AddMinutes(-10).ToUnixTimeSeconds();
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId, expiredTs);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithBadSignature_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: cnf advertises the PoP key, but the SHR is signed by an untrusted key.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.UntrustedKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithWrongIssuerEmbeddedToken_ReturnsUnauthorizedAsync()
+ {
+ // Arrange (objective 3 evidence): embedded AT issuer is not the reused TVP's ValidIssuer.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ "https://sts.windows.net/evil-tenant/", PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithExpiredEmbeddedToken_ReturnsUnauthorizedAsync()
+ {
+ // Arrange (objective 3 evidence): the SHR ts is fresh, but the embedded AT is expired, so the
+ // reused TVP's lifetime validation must reject it.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(-10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithMissingOriginalUriHeader_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: valid SHR, but the request-line contract header is absent.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, originalUri: null, originalMethod: ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithoutAuthorization_ChallengesBearer_NotPoPAsync()
+ {
+ // Arrange: no Authorization header -> the selector must forward to the unchanged Bearer handler.
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.GetAsync("/Validate");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ string wwwAuthenticate = response.Headers.WwwAuthenticate.ToString();
+ Assert.Contains("Bearer", wwwAuthenticate, StringComparison.OrdinalIgnoreCase);
+ // A no-credential request must NOT advertise PoP: the PoP challenge fires only when the caller
+ // actually used the PoP scheme, so the Bearer failure surface is unchanged from today.
+ Assert.DoesNotContain("PoP", wwwAuthenticate, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Validate_WithInvalidPop_Challenges_WwwAuthenticatePoPAsync()
+ {
+ // Arrange: a PoP request that fails validation (SHR signed by an untrusted key). Like the
+ // Bearer path (which returns 401 + WWW-Authenticate: Bearer), the PoP path must answer with a
+ // 401 that advertises its OWN scheme via WWW-Authenticate: PoP - not the wrong scheme.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.UntrustedKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Contains("PoP", response.Headers.WwwAuthenticate.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Validate_WithValidBearerToken_Returns200_AndProtocolBearerAsync()
+ {
+ // Arrange (hard constraint: the Bearer path must keep working, unchanged, through the new
+ // [Bearer, PoP] policy). A plain app-only access token - NO SHR wrapper - signed by the key the
+ // mock IdP publishes via JWKS, presented under the Bearer scheme.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Get, "/Validate");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync(s_webOptions);
+ Assert.NotNull(result);
+ Assert.Equal("Bearer", result!.Protocol);
+ Assert.Equal(accessToken, result.Token);
+ Assert.NotNull(result.Claims);
+ }
+
+ [Fact]
+ public async Task Validate_WithMalformedPopToken_ReturnsUnauthorized_NotServerErrorAsync()
+ {
+ // Arrange: a non-JWT garbage string under the PoP scheme. The validator wraps Wilson in a
+ // try/catch, so this must surface as a clean 401 - never a 500.
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest("not-a-real-shr-jwt", ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithWrongAudienceEmbeddedToken_ReturnsUnauthorizedAsync()
+ {
+ // Arrange (objective 3 evidence): embedded AT audience is not the reused TVP's ValidAudience.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, "api://wrong-audience", DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithEmbeddedTokenMissingCnf_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: the embedded AT carries no cnf claim, so the SHR handler has no confirmation key to
+ // bind the outer signature to - PoP binding must be rejected.
+ string accessToken = PopTestCrypto.CreateAccessTokenWithoutCnf(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10));
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithNonAbsoluteOriginalUri_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: valid SHR, but the original-uri header is a relative path. PopHttpRequestFactory
+ // requires an absolute URI (Uri.TryCreate(..., Absolute)) and must reject otherwise.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, "/relative/path", ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithMissingOriginalMethodHeader_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: valid SHR, but the original-method request-line contract header is absent.
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, originalMethod: null));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_WithInvalidPop_RecordsFailureReasonInServerLogsAsync()
+ {
+ // Arrange: capture server-side logs on an isolated host, then send a PoP request that fails
+ // validation (SHR signed by an untrusted key). JwtBearer logs TokenValidationFailed at
+ // Information; the PoP path must likewise record the reason server-side (the design-doc claim)
+ // even though the 401 challenge returned to the caller stays generic.
+ var logs = new CapturingLoggerProvider();
+ using var loggingFactory = _factory.WithWebHostBuilder(builder =>
+ builder.ConfigureLogging(logging => logging.AddProvider(logs)));
+
+ string accessToken = PopTestCrypto.CreateAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ accessToken, ArmMethod, ArmUri, PopTestCrypto.UntrustedKey, PopTestCrypto.PopKeyId);
+ var client = loggingFactory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert: 401 to the caller, and the specific reason recorded server-side at Information (parity
+ // with JwtBearer's TokenValidationFailed) so the failure is diagnosable from logs.
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Contains(logs.Entries, e =>
+ e.EventId.Name == "Pop_ValidationFailed" &&
+ e.Level == LogLevel.Information &&
+ e.Message.Contains("PoP validation failed", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static HttpRequestMessage BuildPopRequest(string shr, string? originalUri, string? originalMethod)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, "/Validate");
+ request.Headers.Authorization = new AuthenticationHeaderValue("PoP", shr);
+
+ if (originalUri is not null)
+ {
+ request.Headers.TryAddWithoutValidation("original-uri", originalUri);
+ }
+
+ if (originalMethod is not null)
+ {
+ request.Headers.TryAddWithoutValidation("original-method", originalMethod);
+ }
+
+ return request;
+ }
+
+ ///
+ /// SPIKE (throwaway): minimal in-memory that records every emitted
+ /// log entry so a test can assert the PoP handler wrote its failure reason to the server-side log.
+ ///
+ private sealed class CapturingLoggerProvider : ILoggerProvider
+ {
+ public ConcurrentQueue<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = new();
+
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
+
+ public void Dispose()
+ {
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ private readonly ConcurrentQueue<(LogLevel Level, EventId EventId, string Message)> _entries;
+
+ public CapturingLogger(ConcurrentQueue<(LogLevel Level, EventId EventId, string Message)> entries) =>
+ _entries = entries;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) =>
+ _entries.Enqueue((logLevel, eventId, formatter(state, exception)));
+ }
+ }
+}
diff --git a/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj b/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj
index b121bbf87..997179435 100644
--- a/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj
+++ b/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj
@@ -14,6 +14,8 @@
+
+
From 6905c8b34fd8cef48c94dcd283673c6c14d46dad Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:06:52 +0100
Subject: [PATCH 2/9] Enforce app-only on the PoP path + fix misleading scope
comment (spike)
Addresses review feedback on the inbound SHR PoP spike.
- Positively assert the embedded access token is app-only in
ShrPopValidationService: reject a token carrying "scp" (or idtyp != "app")
before reporting Protocol:"PoP". SHR validation authenticates the inner
token but does not distinguish app-only from delegated, so without this a
valid cnf-bound *delegated* token wrapped in an SHR would be accepted - and
because the /Validate PoP branch skips the delegated-scope gate, it would
bypass the AzureAd:Scopes check a Bearer caller faces. Fail-closed: the
handler logs the reason (PopValidationFailed) and returns 401.
- Correct the /Validate comment that claimed skipping the scope check for PoP
is "the same effective outcome as an app-only Bearer under ACL" - only true
when AzureAd:Scopes is unset. Document the divergence and a production open
item (whether PoP needs a symmetric app-permission/roles gate).
- Tests: add CreateDelegatedAccessToken and a
Validate_WithDelegatedEmbeddedToken_ReturnsUnauthorizedAsync negative
proving a delegated token in an otherwise-valid SHR is rejected.
ShrPopValidationTests 19/19; sidecar Release build 0 warnings (AOT clean).
Throwaway spike - not for production.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Endpoints/ValidateRequestEndpoints.cs | 11 +++--
.../Pop/ShrPopValidationService.cs | 37 ++++++++++++++++
.../E2E Tests/Sidecar.Tests/PopTestCrypto.cs | 42 +++++++++++++++++++
.../Sidecar.Tests/ShrPopValidationTests.cs | 21 ++++++++++
4 files changed, 108 insertions(+), 3 deletions(-)
diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
index 5a1a0ba42..c5a0c54b0 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
@@ -31,9 +31,14 @@ private static Results, ProblemHttpResult>
[FromServices] IConfiguration configuration)
{
// SPIKE (throwaway): inbound SHR PoP branch. The PoP authentication handler stashes the
- // validated inner (app-only) access token here after re-hosted MISE SHR validation succeeds.
- // App-only tokens carry roles, not scopes, so the scope check below is skipped for PoP - the
- // same effective outcome as an app-only Bearer token under AllowWebApiToBeAuthorizedByACL.
+ // validated inner access token here after re-hosted MISE SHR validation succeeds - and only
+ // after ShrPopValidationService has asserted it is app-only (a delegated/user token is rejected
+ // there). App-only tokens carry roles, not the 'scp' claim, so the delegated-scope gate below
+ // (AzureAd:Scopes -> VerifyUserHasAnyAcceptedScope, which matches on 'scp') does not apply to
+ // PoP and is intentionally skipped. NOTE this is deliberately NOT identical to the sidecar's
+ // default token path when AzureAd:Scopes is set: there a token without an 'scp' claim is 403'd,
+ // whereas the PoP path relies on the app-only assertion above instead. Production open item:
+ // decide whether PoP needs a symmetric app-permission (roles) authorization gate.
if (httpContext.Items[PopConstants.ValidatedAccessTokenItemKey] is JsonWebToken popToken)
{
return BuildResult(logger, PopConstants.ProtocolName, popToken);
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
index 67854c5de..3a7283195 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
@@ -129,6 +129,43 @@ accessTokenResult.SecurityToken is not JsonWebToken validatedAccessToken ||
return ShrPopValidationResult.Fail("Embedded access token validation failed.");
}
+ // SPIKE (throwaway): enforce the declared app-only scope. SHR/inner-AT validation above proves
+ // the embedded token is authentic but does NOT distinguish an app-only (client-credentials)
+ // token from a delegated/user one. Without this guard a valid, cnf-bound *delegated* token
+ // wrapped in an SHR would be accepted as PoP - and because the /Validate PoP branch skips the
+ // delegated-scope (scp) gate, it would sail past the AzureAd:Scopes check a Bearer caller faces.
+ // Reject anything that is not app-only; fail-closed (the handler logs the reason and 401s).
+ if (!IsAppOnlyToken(validatedAccessToken))
+ {
+ return ShrPopValidationResult.Fail(
+ "The embedded access token is not an app-only token; inbound SHR PoP is scoped to app-only (client-credentials) tokens.");
+ }
+
return ShrPopValidationResult.Success(validatedAccessToken, accessTokenResult.ClaimsIdentity);
}
+
+ ///
+ /// Distinguishes an app-only (client-credentials) token from a delegated/user token. Per Entra
+ /// token semantics a delegated token carries the scp (scope) claim while an app-only token
+ /// does not; the optional idtyp claim, when emitted, states this authoritatively
+ /// ("app" vs "user"). A token is treated as app-only only when it has no scp
+ /// AND (no idtyp, or idtyp == "app"). idtyp presence is not required because it
+ /// is an optional claim many tenants do not emit. Entra always emits scp as a space-delimited
+ /// string and the token is signed/validated, so a caller cannot evade this by reshaping the claim.
+ ///
+ private static bool IsAppOnlyToken(JsonWebToken token)
+ {
+ if (token.TryGetPayloadValue("scp", out _))
+ {
+ return false;
+ }
+
+ if (token.TryGetPayloadValue("idtyp", out string? idtyp) &&
+ !string.Equals(idtyp, "app", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs b/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
index 5ab47695a..fda03848f 100644
--- a/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
+++ b/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
@@ -123,6 +123,48 @@ public static string CreateAccessTokenWithCnf(
public static string CreateAccessTokenWithoutCnf(string issuer, string audience, DateTime expires) =>
CreateAccessTokenWithCnf(issuer, audience, expires, cnf: null);
+ ///
+ /// Mints a DELEGATED (user) access token bound (via cnf.jwk) to : it
+ /// carries the scp claim and idtyp "user" (and no app roles). Used to prove the
+ /// sidecar's app-only guard rejects a delegated token wrapped in an otherwise-valid SHR - inbound
+ /// SHR PoP is scoped to app-only (client-credentials) tokens.
+ ///
+ public static string CreateDelegatedAccessToken(
+ string issuer,
+ string audience,
+ DateTime expires,
+ RSA cnfKey,
+ string cnfKeyId)
+ {
+ var handler = new JsonWebTokenHandler();
+ DateTime now = DateTime.UtcNow;
+
+ var claims = new Dictionary
+ {
+ ["sub"] = "22222222-2222-2222-2222-222222222222",
+ ["oid"] = "22222222-2222-2222-2222-222222222222",
+ ["appid"] = audience,
+ ["idtyp"] = "user",
+ ["scp"] = "user_impersonation",
+ ["cnf"] = BuildCnf(cnfKey, cnfKeyId),
+ };
+
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = issuer,
+ Audience = audience,
+ IssuedAt = now,
+ NotBefore = now.AddMinutes(-5),
+ Expires = expires,
+ SigningCredentials = new SigningCredentials(
+ new RsaSecurityKey(AccessTokenSigningKey) { KeyId = AccessTokenKeyId },
+ SecurityAlgorithms.RsaSha256),
+ Claims = claims,
+ };
+
+ return handler.CreateToken(descriptor);
+ }
+
///
/// Creates an SHR over the given method + URI, signed by .
/// When is supplied, that exact (signed) ts is embedded
diff --git a/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs b/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
index 6effed105..2e6f968ed 100644
--- a/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
@@ -82,6 +82,27 @@ public async Task Validate_FullLoop_TokenAcquiredFromMockIdp_Returns200_AndProto
Assert.NotNull(result.Claims);
}
+ [Fact]
+ public async Task Validate_WithDelegatedEmbeddedToken_ReturnsUnauthorizedAsync()
+ {
+ // Arrange: a fully valid, cnf-bound SHR whose EMBEDDED token is a DELEGATED (user) token
+ // (carries 'scp' / idtyp "user"). The outer SHR and the inner signature are otherwise valid,
+ // so this isolates the app-only guard: inbound SHR PoP is scoped to app-only, so the sidecar
+ // must reject a delegated token wrapped in an SHR.
+ string delegatedToken = PopTestCrypto.CreateDelegatedAccessToken(
+ PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
+ PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ string shr = PopTestCrypto.CreateSignedHttpRequest(
+ delegatedToken, ArmMethod, ArmUri, PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
+ var client = _factory.CreateClient();
+
+ // Act
+ var response = await client.SendAsync(BuildPopRequest(shr, ArmUri, ArmMethod));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
[Fact]
public async Task Validate_WithTamperedMethod_ReturnsUnauthorizedAsync()
{
From 2b692d281f0406a135bd0f91ba128d2ba088a2ce Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:31:09 +0100
Subject: [PATCH 3/9] Add real-Entra E2E pipeline tests for inbound SHR PoP
validation
Adds SidecarPopEndpointsE2ETests (design-doc S9 "E2E (pipeline)" deliverable),
gated behind #if !FROM_GITHUB_ACTION like the sibling Bearer E2E suite. Each
test acquires a REAL app-only token from Entra via MSIdWeb
(GetAuthenticationResultForAppAsync + PoPAuthenticationConfiguration over an
ARM-shaped request line) and drives it through the sidecar's real AzureAd
discovery/JWKS path.
Coverage mirrors the Bearer E2E surface and adds the PoP-specific guarantees
that only a real token proves end-to-end (9 tests):
- Happy: real PoP -> Protocol "PoP"; real Bearer control -> Protocol "Bearer".
- Bad credential: garbage PoP -> 401 with PoP invalid_token challenge.
- Request-line BINDING vs a real SHR: tampered method, tampered URI, and
missing request-line headers -> 401 (anti-replay guarantee).
- Scheme ISOLATION with real tokens: real SHR on Bearer -> 401; real Bearer
on PoP -> 401 (schemes never cross-validate).
- No credential -> 401 advertising Bearer.
Verified: all 9 PASS against the real lab tenant. Confirms the lab agent app
issues cnf-bound PoP tokens for the sidecar scope. Uses CurrentUser/My locally
to avoid the LocalMachine private-key ACL/elevation requirement.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../SidecarPopEndpointsE2ETests.cs | 288 ++++++++++++++++++
1 file changed, 288 insertions(+)
create mode 100644 tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs
diff --git a/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs b/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs
new file mode 100644
index 000000000..b498514ae
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs
@@ -0,0 +1,288 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#if !FROM_GITHUB_ACTION
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Identity.Abstractions;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.AppConfig;
+using Microsoft.Identity.Web;
+using Microsoft.Identity.Web.Sidecar.Models;
+using Microsoft.Identity.Web.TokenCacheProviders.InMemory;
+using Xunit;
+
+namespace Sidecar.Tests;
+
+///
+/// SPIKE (throwaway) END-TO-END pipeline tests for inbound SHR PoP validation - the design-doc §9
+/// "E2E (pipeline)" deliverable. Unlike (which mints tokens from an
+/// in-process mock IdP), these exercise the WHOLE real path: a REAL app-only token is acquired from
+/// Entra, cnf-bound and wrapped in an ARM-shaped Signed HTTP Request by MSAL (via MSIdWeb's
+/// WithSignedHttpRequestProofOfPossession), then validated by the sidecar through its REAL AzureAd
+/// discovery/JWKS path (configured on ).
+///
+/// Coverage mirrors the Bearer E2E surface ( +
+/// ) and adds the PoP-specific guarantees that only a real
+/// token can prove end-to-end:
+/// - Happy paths: real PoP → Protocol "PoP"; real Bearer control → Protocol "Bearer".
+/// - Bad credential: garbage PoP → 401 (clean, not 500) with a PoP challenge.
+/// - Request-line BINDING against a real SHR: tampered method, tampered URI, and missing request-line
+/// headers all → 401 (the anti-replay guarantee, proven against an ESTS-minted SHR + real key).
+/// - Scheme ISOLATION with real tokens: a real SHR presented as Bearer → 401, and a real Bearer token
+/// presented as PoP → 401 (the two schemes never cross-validate).
+/// - No credential → 401 advertising Bearer.
+///
+/// GATING: compiled out of GitHub Actions (FROM_GITHUB_ACTION) and, like the sibling Bearer E2E suite,
+/// requires lab-tenant credentials to RUN:
+/// - the certificate CN=LabAuth.MSIDLab.com present in a reachable store (see CertificateStorePath), and
+/// - the client app () registered and permitted to acquire an
+/// SHR PoP (cnf-bound) app-only token for .
+/// Intended to run behind a pipeline stage gate that supplies those credentials; without them the
+/// token-acquisition step throws (an environment failure), exactly like the sibling E2E suite.
+///
+public class SidecarPopEndpointsE2ETests : IClassFixture
+{
+ private readonly SidecarApiFactory _factory;
+
+ public SidecarPopEndpointsE2ETests(SidecarApiFactory factory) => _factory = factory;
+
+ const string TenantId = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; // Replace with your tenant ID
+ const string AgentApplication = "aab5089d-e764-47e3-9f28-cc11c2513821"; // Sidecar's AzureAd ClientId/Audience (see SidecarApiFactory)
+ const string TestClientApplication = "825940df-c1fb-4604-8104-02965f55b1ee"; // Replace with the client used for app-only calls
+ const string Instance = "https://login.microsoftonline.com/"; // Entra ID authority instance
+ const string CertificateStorePath = "CurrentUser/My"; // Local dev: CurrentUser avoids the LocalMachine private-key ACL/elevation requirement (pipeline may use LocalMachine/My)
+ const string CertificateDistinguishedName = "CN=LabAuth.MSIDLab.com"; // Replace with the certificate subject name
+ static readonly string AgentApplicationScope = $"api://{AgentApplication}/.default";
+
+ // An ARM-shaped request line the SHR is signed over: this binds the SHR's m/u/p. The INNER token's
+ // audience is still the sidecar's configured AzureAd audience (AgentApplication), which is what the
+ // sidecar validates the embedded access token against - the SHR host/path need not equal that.
+ private const string SignedUri =
+ "https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1?api-version=2023-07-01";
+ private const string SignedMethod = "POST";
+
+ // A DIFFERENT absolute URI (different resource/path) used to prove path binding: presenting this as
+ // original-uri against an SHR signed over SignedUri must fail the 'p' (and here 'u' is same host) check.
+ private const string DifferentUri =
+ "https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm2?api-version=2023-07-01";
+
+ private static readonly JsonSerializerOptions s_webOptions = new(JsonSerializerDefaults.Web);
+
+ // ---------------------------------------------------------------------------------------------
+ // Happy paths
+ // ---------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Validate_RealEntraAppOnlyShrPop_Returns200_AndProtocolPoPAsync()
+ {
+ // Acquire a REAL app-only SHR PoP token from Entra, signed over the ARM-shaped request, and send
+ // it with the matching request-line headers. The sidecar validates the outer SHR + the inner AT
+ // (via the reused Bearer TVP over live JWKS) and reports Protocol "PoP".
+ string shr = await AcquireRealShrAsync(SignedMethod, SignedUri);
+
+ var response = await SendToValidateAsync("PoP", shr, SignedMethod, SignedUri);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync(s_webOptions);
+ Assert.NotNull(result);
+ Assert.Equal("PoP", result!.Protocol);
+ Assert.NotNull(result.Claims);
+ }
+
+ [Fact]
+ public async Task Validate_RealEntraBearer_StillReturns200_AndProtocolBearerAsync()
+ {
+ // The multi-scheme [Bearer, PoP] policy must not disturb Bearer. A plain app-only token (no
+ // PoPConfiguration) sent on the Bearer scheme still validates and reports Protocol "Bearer".
+ string bearer = await AcquireRealBearerAsync();
+
+ var response = await SendToValidateAsync("Bearer", bearer, originalMethod: null, originalUri: null);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync(s_webOptions);
+ Assert.NotNull(result);
+ Assert.Equal("Bearer", result!.Protocol);
+ Assert.NotNull(result.Claims);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Bad credential (mirrors the Bearer bad-token E2E test)
+ // ---------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Validate_RealEntraInvalidPopToken_ReturnsUnauthorizedAsync()
+ {
+ // A syntactically-bogus PoP credential must fail closed with a clean 401 (never a 500) and carry
+ // the generic PoP challenge alongside Bearer.
+ var response = await SendToValidateAsync("PoP", "not-a-valid-shr", SignedMethod, SignedUri);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ string wwwAuthenticate = response.Headers.WwwAuthenticate.ToString();
+ Assert.Contains("PoP", wwwAuthenticate, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("invalid_token", wwwAuthenticate, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Request-line binding against a REAL SHR (the anti-replay guarantee)
+ // ---------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Validate_RealEntraShrPop_TamperedMethod_ReturnsUnauthorizedAsync()
+ {
+ // Real SHR signed over POST, but the caller claims the method was GET -> 'm' binding fails -> 401.
+ string shr = await AcquireRealShrAsync(SignedMethod, SignedUri);
+
+ var response = await SendToValidateAsync("PoP", shr, originalMethod: "GET", originalUri: SignedUri);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_RealEntraShrPop_TamperedUri_ReturnsUnauthorizedAsync()
+ {
+ // Real SHR signed over vm1, but the caller claims a different URI (vm2) -> 'p' binding fails -> 401.
+ string shr = await AcquireRealShrAsync(SignedMethod, SignedUri);
+
+ var response = await SendToValidateAsync("PoP", shr, originalMethod: SignedMethod, originalUri: DifferentUri);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_RealEntraShrPop_MissingRequestLineHeaders_ReturnsUnauthorizedAsync()
+ {
+ // Real SHR, but no original-method / original-uri headers -> the sidecar cannot reconstruct the
+ // signed request line, so validation fails closed -> 401.
+ string shr = await AcquireRealShrAsync(SignedMethod, SignedUri);
+
+ var response = await SendToValidateAsync("PoP", shr, originalMethod: null, originalUri: null);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Scheme isolation with REAL tokens (the two schemes never cross-validate)
+ // ---------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Validate_RealEntraShrPop_PresentedOnBearerScheme_ReturnsUnauthorizedAsync()
+ {
+ // A real SHR is not a plain access token. Presented on the Bearer scheme, JwtBearer rejects it
+ // (wrong signer/issuer/audience) and the PoP handler never runs -> 401. Proves an SHR cannot slip
+ // through as a Bearer token.
+ string shr = await AcquireRealShrAsync(SignedMethod, SignedUri);
+
+ var response = await SendToValidateAsync("Bearer", shr, originalMethod: null, originalUri: null);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Validate_RealEntraBearerToken_PresentedOnPopScheme_ReturnsUnauthorizedAsync()
+ {
+ // A plain Bearer access token is not a Signed HTTP Request. Presented on the PoP scheme, SHR
+ // validation fails (it is not a signed SHR) -> 401. Proves a captured Bearer token cannot be
+ // replayed through the PoP path.
+ string bearer = await AcquireRealBearerAsync();
+
+ var response = await SendToValidateAsync("PoP", bearer, SignedMethod, SignedUri);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // No credential (mirrors the Bearer no-auth E2E test + design-doc "no credential -> advertise Bearer")
+ // ---------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Validate_WithoutAuthorization_ReturnsUnauthorized_ChallengesBearerAsync()
+ {
+ var response = await _factory.CreateClient().GetAsync("/Validate");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Contains("Bearer", response.Headers.WwwAuthenticate.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------------------------
+
+ // Acquires a REAL app-only SHR PoP token from Entra, cnf-bound and signed over (method, uri).
+ private static async Task AcquireRealShrAsync(string method, string uri)
+ {
+ ITokenAcquisition tokenAcquisition = CreateAppTokenAcquisition();
+ using var signedRequest = new HttpRequestMessage(new HttpMethod(method), new Uri(uri));
+
+ AuthenticationResult authResult = await tokenAcquisition.GetAuthenticationResultForAppAsync(
+ AgentApplicationScope,
+ tokenAcquisitionOptions: new TokenAcquisitionOptions
+ {
+ PoPConfiguration = new PoPAuthenticationConfiguration(signedRequest),
+ });
+
+ // Sanity: ESTS/MSAL actually issued a PoP (SHR) token, not a plain Bearer.
+ Assert.Equal("PoP", authResult.TokenType, ignoreCase: true);
+ return authResult.AccessToken;
+ }
+
+ // Acquires a plain app-only Bearer access token from Entra (no PoP).
+ private static async Task AcquireRealBearerAsync()
+ {
+ ITokenAcquisition tokenAcquisition = CreateAppTokenAcquisition();
+ AuthenticationResult authResult =
+ await tokenAcquisition.GetAuthenticationResultForAppAsync(AgentApplicationScope);
+ return authResult.AccessToken;
+ }
+
+ // Sends GET /Validate with the given Authorization scheme/credential and optional request-line headers.
+ private async Task SendToValidateAsync(
+ string scheme, string credential, string? originalMethod, string? originalUri)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, "/Validate");
+ request.Headers.Authorization = new AuthenticationHeaderValue(scheme, credential);
+
+ // Request-line contract headers (identical to the MISE container: original-method / original-uri).
+ // The app supplies these because /Validate receives a validation request, not the signed request.
+ if (originalMethod is not null)
+ {
+ request.Headers.TryAddWithoutValidation("original-method", originalMethod);
+ }
+
+ if (originalUri is not null)
+ {
+ request.Headers.TryAddWithoutValidation("original-uri", originalUri);
+ }
+
+ return await _factory.CreateClient().SendAsync(request);
+ }
+
+ // Builds an app-only ITokenAcquisition against the real lab tenant, using the same certificate the
+ // sibling E2E suite uses (mirrors SidecarEndpointsE2ETests.GetAuthorizationHeaderToCallTheSideCarAsync).
+ private static ITokenAcquisition CreateAppTokenAcquisition()
+ {
+ ServiceCollection services = new();
+ IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection().Build();
+ services.AddSingleton(configuration);
+ configuration["Instance"] = Instance;
+ configuration["TenantId"] = TenantId;
+ configuration["ClientId"] = TestClientApplication;
+ configuration["SendX5C"] = "true";
+ configuration["ClientCredentials:0:SourceType"] = "StoreWithDistinguishedName";
+ configuration["ClientCredentials:0:CertificateStorePath"] = CertificateStorePath;
+ configuration["ClientCredentials:0:CertificateDistinguishedName"] = CertificateDistinguishedName;
+
+ services.AddTokenAcquisition().AddHttpClient().AddInMemoryTokenCaches();
+ services.Configure(configuration);
+ IServiceProvider serviceProvider = services.BuildServiceProvider();
+
+ return serviceProvider.GetRequiredService();
+ }
+}
+
+#endif
From b4b1eb1dd576d196fe9ef8c6500ad3a0df5fa947 Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:34:46 +0100
Subject: [PATCH 4/9] Harden inbound SHR proof-of-possession validation in the
sidecar
Refine the inbound Signed HTTP Request (SHR) proof-of-possession validation
for the sidecar: professionalize comments and documentation to repo
conventions and address review feedback.
- Guard the SHR validation catch against OperationCanceledException
- Remove the unused original-IP header constant
- Clarify the reused JwtBearer TokenValidationParameters / ConfigurationManager fallback
- Document the /Validate PoP path, trusted-header and replay-window caveats in the README
- Align test certificate store and test naming with repo conventions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Configuration/PopValidationOptions.cs | 38 +++++-----
.../Configuration/SidecarOptions.cs | 4 +-
.../Endpoints/ValidateRequestEndpoints.cs | 13 +---
.../Logging/Logging.cs | 2 +-
.../Microsoft.Identity.Web.Sidecar.csproj | 1 -
.../Pop/PopAuthenticationExtensions.cs | 21 +++---
.../Pop/PopAuthenticationHandler.cs | 29 ++------
.../Pop/PopConstants.cs | 19 ++---
.../Pop/PopHttpRequestFactory.cs | 16 ++--
.../Pop/ShrPopValidationResult.cs | 2 +-
.../Pop/ShrPopValidationService.cs | 74 ++++++++-----------
src/Microsoft.Identity.Web.Sidecar/Program.cs | 6 +-
.../Properties/InternalsVisibleTo.cs | 2 +-
src/Microsoft.Identity.Web.Sidecar/README.md | 51 ++++++++++++-
.../E2E Tests/Sidecar.Tests/MockIdpServer.cs | 2 +-
.../Sidecar.Tests/PopSidecarApiFactory.cs | 6 +-
.../E2E Tests/Sidecar.Tests/PopTestCrypto.cs | 2 +-
.../PopValidationOptionsMappingTests.cs | 12 +--
.../Sidecar.Tests/ShrPopValidationTests.cs | 16 ++--
.../Sidecar.Tests/Sidecar.Tests.csproj | 2 +-
.../SidecarPopEndpointsE2ETests.cs | 19 +++--
21 files changed, 164 insertions(+), 173 deletions(-)
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
index 456b0f231..5e9ad7c94 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
@@ -7,22 +7,19 @@
namespace Microsoft.Identity.Web.Sidecar.Configuration;
///
-/// SPIKE (throwaway): operator-tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession
-/// validation, bound from the Sidecar:PopValidation configuration subsection. Every property
-/// defaults to the strict MISE SignedHttpRequestValidationOptions default, so when the subsection
-/// is absent the mapped SignedHttpRequestValidationParameters are the current behavior (m/u/p/ts
-/// ON; q/h OFF; unsigned headers/query accepted; 5-minute lifetime; nonce unset).
+/// tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession validation, bound
+/// from the Sidecar:PopValidation configuration subsection. Every property defaults to the
+/// secure Microsoft.IdentityModel SignedHttpRequestValidationParameters default, so when the
+/// subsection is absent PoP validation runs with method/URI/path/timestamp binding on, query/header
+/// binding off, unsigned headers and query parameters accepted, and a five-minute lifetime.
///
-/// Only the config-clean members Wilson exposes as simple values are surfaced here. Deliberately NOT
-/// configurable (see the design doc): body-hash b (needs request-body buffering; MISE never
-/// surfaces it), jku key resolution (introduces outbound egress; the cnf jwk-vs-jku open item),
-/// and all delegate members (nonce/replay/signature/key-resolver) which are code, not configuration.
+/// Only the members exposed as simple configuration values are surfaced here. Body-hash (b)
///
///
public class PopValidationOptions
{
///
- /// Gets or sets a value indicating whether the ts (timestamp) claim is validated. This is the
+ /// Gets or sets a value indicating whether the ts (timestamp) claim is validated - the
/// replay/freshness guard. Default true; disabling it removes replay protection.
///
[DefaultValue(true)]
@@ -51,52 +48,51 @@ public class PopValidationOptions
///
/// Gets or sets a value indicating whether the q (query parameters) claim is validated -
- /// request binding. Default false (matches MISE); enabling it hardens binding but the caller
- /// must sign q or every request fails.
+ /// request binding. Default false; enabling it hardens binding but the caller must sign
+ /// q or every request fails.
///
[DefaultValue(false)]
public bool ValidateQ { get; set; }
///
/// Gets or sets a value indicating whether the h (headers) claim is validated - request
- /// binding. Default false (matches MISE); enabling it hardens binding but the caller must sign
- /// h or every request fails.
+ /// binding. Default false; enabling it hardens binding but the caller must sign h or
+ /// every request fails.
///
[DefaultValue(false)]
public bool ValidateH { get; set; }
///
/// Gets or sets a value indicating whether headers not covered by the signature are accepted.
- /// Default true (matches MISE).
+ /// Default true.
///
[DefaultValue(true)]
public bool AcceptUnsignedHeaders { get; set; } = true;
///
/// Gets or sets a value indicating whether query parameters not covered by the signature are
- /// accepted. Default true (matches MISE).
+ /// accepted. Default true.
///
[DefaultValue(true)]
public bool AcceptUnsignedQueryParameters { get; set; } = true;
///
/// Gets or sets a value indicating whether the claims listed in
- /// are validated when present (even if their individual flag is off). Default false (matches
- /// MISE); while false the list is inert.
+ /// are validated when present (even if their individual flag is off). Default false; while
+ /// false the list is inert.
///
[DefaultValue(false)]
public bool ValidatePresentClaims { get; set; }
///
/// Gets or sets the signed HTTP request lifetime - the ts clock-skew tolerance. Default
- /// 5 minutes (matches MISE).
+ /// 5 minutes.
///
public TimeSpan SignedHttpRequestLifetime { get; set; } = TimeSpan.FromMinutes(5);
///
/// Gets or sets the claims validated only when present, gated by .
- /// Default { "m", "p" } (matches MISE). Inert unless is
- /// true.
+ /// Default { "m", "p" }. Inert unless is true.
///
public IList ClaimsToValidateWhenPresent { get; set; } = new Collection { "m", "p" };
}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
index 71037bb27..f60e5c19d 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs
@@ -25,9 +25,9 @@ public class SidecarOptions
public bool AllowOutboundRedirects { get; set; }
///
- /// SPIKE (throwaway): operator-tunable inbound SHR PoP validation flags, bound from the
+ /// Gets or sets the tunable inbound SHR PoP validation flags, bound from the
/// Sidecar:PopValidation subsection. When the subsection is absent every flag keeps its
- /// strict MISE default, so PoP validation behaves exactly as the original hard-coded PoC.
+ /// secure default, so PoP validation runs with method/URI/path/timestamp binding on.
///
public PopValidationOptions PopValidation { get; set; } = new();
}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
index c5a0c54b0..1d982a102 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
@@ -30,15 +30,10 @@ private static Results, ProblemHttpResult>
HttpContext httpContext,
[FromServices] IConfiguration configuration)
{
- // SPIKE (throwaway): inbound SHR PoP branch. The PoP authentication handler stashes the
- // validated inner access token here after re-hosted MISE SHR validation succeeds - and only
- // after ShrPopValidationService has asserted it is app-only (a delegated/user token is rejected
- // there). App-only tokens carry roles, not the 'scp' claim, so the delegated-scope gate below
- // (AzureAd:Scopes -> VerifyUserHasAnyAcceptedScope, which matches on 'scp') does not apply to
- // PoP and is intentionally skipped. NOTE this is deliberately NOT identical to the sidecar's
- // default token path when AzureAd:Scopes is set: there a token without an 'scp' claim is 403'd,
- // whereas the PoP path relies on the app-only assertion above instead. Production open item:
- // decide whether PoP needs a symmetric app-permission (roles) authorization gate.
+ // A validated inbound SHR PoP request: the PoP handler has already validated the outer signature
+ // and the embedded app-only access token (delegated/user tokens are rejected there) and stashed
+ // it here. Scope enforcement (AzureAd:Scopes -> VerifyUserHasAnyAcceptedScope) targets delegated
+ // 'scp' claims and so does not apply to app-only PoP; the app-only guard above is the equivalent gate.
if (httpContext.Items[PopConstants.ValidatedAccessTokenItemKey] is JsonWebToken popToken)
{
return BuildResult(logger, PopConstants.ProtocolName, popToken);
diff --git a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
index ed9c1b496..b9f22d1c5 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
@@ -42,7 +42,7 @@ public static partial class LoggerMessageExtensions
// Mirrors JwtBearer's TokenValidationFailed (also LogLevel.Information): record the reason an
// inbound SHR PoP request failed validation server-side without disclosing it to the caller. The
- // reason is a Wilson validation message (e.g. lifetime/signature/audience) and never the token.
+ // reason is an identity-model validation message (e.g. lifetime/signature/audience) and never the token.
[LoggerMessage(
EventId = 6,
Level = LogLevel.Information,
diff --git a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj
index 51627780b..ca2de0488 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj
+++ b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj
@@ -31,7 +31,6 @@
-
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
index ed55f9b58..66c021abb 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
@@ -9,16 +9,16 @@
namespace Microsoft.Identity.Web.Sidecar.Pop;
///
-/// SPIKE (throwaway): wires inbound SHR PoP into the sidecar's authentication pipeline WITHOUT
-/// changing the Bearer path or the global default authentication scheme.
-///
-/// It registers a second authentication handler under the "PoP" scheme and a named authorization
-/// policy () that accepts BOTH "Bearer" and "PoP". Only
-/// the /Validate endpoint opts into that policy, so every other endpoint - and the default
-/// scheme - remains exactly as before. For a Bearer request the PoP handler no-ops (wrong scheme) and
-/// JwtBearer validates as usual; for a PoP request the JwtBearer handler no-ops and the PoP handler
-/// runs the re-hosted MISE SHR validation.
+/// Wires inbound SHR PoP into the sidecar's authentication pipeline without changing the Bearer scheme
+/// or the global default authentication scheme.
///
+///
+/// Registers a second authentication handler under the "PoP" scheme and a named authorization policy
+/// () that accepts both "Bearer" and "PoP". Only the
+/// /Validate endpoint opts into that policy, so every other endpoint - and the default scheme -
+/// is unchanged. A Bearer request is validated by JwtBearer while the PoP handler no-ops; a PoP request
+/// is validated by the PoP handler while JwtBearer no-ops.
+///
internal static class PopAuthenticationExtensions
{
public static AuthenticationBuilder AddInboundShrPop(this AuthenticationBuilder builder)
@@ -29,8 +29,7 @@ public static AuthenticationBuilder AddInboundShrPop(this AuthenticationBuilder
PopConstants.SchemeName,
static _ => { });
- // Scope PoP to /Validate via a policy that authenticates Bearer OR PoP. The global default
- // authentication scheme is intentionally left as "Bearer" so nothing else changes.
+ // Accept both Bearer and PoP on /Validate. The global default scheme stays "Bearer".
builder.Services
.AddAuthorizationBuilder()
.AddPolicy(PopConstants.ValidatePolicyName, policy =>
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs
index c91c5c2d3..5b53b46e7 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationHandler.cs
@@ -11,18 +11,16 @@
namespace Microsoft.Identity.Web.Sidecar.Pop;
-/// SPIKE (throwaway): options for the PoP authentication scheme.
+/// Options for the PoP authentication scheme.
internal sealed class PopAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
}
///
-/// SPIKE (throwaway): ASP.NET Core authentication handler for inbound SHR PoP. This is the
-/// sidecar-native equivalent of MISE's PoP protocol parser + validation rule: it recognises the
-/// "PoP" Authorization scheme, runs , and on success produces a
-/// from the validated inner access token. The existing Bearer
-/// (JwtBearer) handler is untouched; the /Validate endpoint opts into a
-/// multi-scheme authorization policy that accepts both Bearer and PoP.
+/// ASP.NET Core authentication handler for inbound SHR PoP. It recognises the "PoP" Authorization
+/// scheme, runs , and on success builds a
+/// from the validated inner access token. The Bearer (JwtBearer) handler
+/// is untouched; the /Validate endpoint opts into a policy that accepts both schemes.
///
internal sealed class PopAuthenticationHandler : AuthenticationHandler
{
@@ -60,9 +58,6 @@ protected override async Task HandleAuthenticateAsync()
if (!result.Succeeded)
{
string error = result.Error ?? "PoP validation failed.";
-
- // Record the reason server-side (parity with JwtBearer's TokenValidationFailed) so the
- // failure is diagnosable from logs while the challenge response stays generic.
Logger.PopValidationFailed(error);
return AuthenticateResult.Fail(error);
}
@@ -74,20 +69,6 @@ protected override async Task HandleAuthenticateAsync()
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
-
- // SPIKE (throwaway): mirror JwtBearer's failure surface for the PoP scheme. Stock JwtBearer
- // answers an auth failure with 401 + "WWW-Authenticate: Bearer error=...". The /Validate policy
- // lists BOTH schemes, so on failure ASP.NET Core challenges each: the JwtBearer handler emits its
- // Bearer challenge (unchanged), and this override emits the matching "WWW-Authenticate: PoP" so a
- // PoP caller is told PoP is the scheme in play instead of only (misleadingly) Bearer. The header
- // is appended (never overwritten), and only when the caller actually used the PoP scheme - so a
- // plain Bearer or no-credential 401 still advertises Bearer alone, exactly as before.
- //
- // We deliberately emit only error="invalid_token" and no verbose error_description: unlike
- // Bearer's IncludeErrorDetails default, we avoid echoing internal SHR validation text into a
- // response header (matching MISE's DPoP response-header creator, which keeps error_description
- // generic). The precise reason is available in the server-side log; the description is an easy
- // future knob, and the nonce challenge params (nonce=/error="use_dpop_nonce") are additive later.
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
if (AuthenticationHeaderValue.TryParse(Request.Headers.Authorization.ToString(), out AuthenticationHeaderValue? headerValue) &&
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
index 9014d9d77..e0eb64120 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
@@ -6,37 +6,30 @@
namespace Microsoft.Identity.Web.Sidecar.Pop;
///
-/// SPIKE (throwaway): constants for the inbound Signed HTTP Request (SHR) Proof-of-Possession path.
-/// The "PoP" scheme token and the request-line header names mirror the MISE container contract
-/// (ContainerShared/RequestHeaderNames.cs) so a future shared Envoy front-end stays wire-compatible.
+/// Constants for the inbound Signed HTTP Request (SHR) Proof-of-Possession path: the "PoP"
+/// Authorization scheme token and the request-line contract header names.
///
internal static class PopConstants
{
- /// The Authorization scheme token for SHR PoP ("PoP"). Sourced from Wilson so it never drifts.
+ /// The Authorization scheme token for SHR PoP ("PoP"). Sourced from Microsoft.IdentityModel so it never drifts.
public const string SchemeName = SignedHttpRequestConstants.AuthorizationHeaderSchemeName;
/// Protocol label returned to the caller for a validated PoP request.
public const string ProtocolName = "PoP";
///
- /// Name of the authorization policy applied to the /Validate endpoint. The policy accepts
- /// BOTH the "Bearer" and "PoP" authentication schemes, so a single endpoint transparently handles
- /// either credential without changing the global default scheme (which stays "Bearer"). This keeps
- /// the change scoped to /Validate; every other endpoint is completely unaffected.
+ /// Name of the authorization policy applied to the /Validate endpoint. It accepts both the
+ /// "Bearer" and "PoP" schemes so a single endpoint handles either credential without changing the
+ /// global default scheme (which stays "Bearer").
///
public const string ValidatePolicyName = "SidecarValidateBearerOrPoP";
- // --- Request-line contract headers (identical to MISE ContainerShared/RequestHeaderNames.cs) ---
-
/// Header carrying the original (signed) request absolute URI.
public const string OriginalUriHeaderName = "original-uri";
/// Header carrying the original (signed) request HTTP method.
public const string OriginalMethodHeaderName = "original-method";
- /// Header carrying the original client IP (reserved; not used by m/u/p/ts validation).
- public const string OriginalIpAddressHeaderName = "x-forwarded-for";
-
/// HttpContext.Items key holding the validated inner access token (a JsonWebToken).
public const string ValidatedAccessTokenItemKey = "Sidecar.Pop.ValidatedAccessToken";
}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
index 263aed2fe..f7df4fdcd 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
@@ -6,17 +6,15 @@
namespace Microsoft.Identity.Web.Sidecar.Pop;
///
-/// SPIKE (throwaway): builds the Wilson that SHR validation binds the
-/// signature against, from the request-line contract headers (original-uri / original-method).
+/// Builds the that SHR validation binds the signature against, from the
+/// request-line contract headers (original-uri / original-method).
///
///
-/// Design decision (flagged for the design doc): the sidecar is app-invoked today (not Envoy-fronted).
-/// The call to /Validate is NOT the request the SHR was signed over, so the calling app MUST
-/// supply the original method + URI - there is no safe server-side derivation. Hence these headers are
-/// REQUIRED for PoP, a deliberate deviation from MISE (which can fall back to the Envoy-provided
-/// Host/method). The header names are kept identical to MISE so a later Envoy front-end and the MISE
-/// container remain consistent. Only Uri + Method are needed because v1 validates m/u/p/ts (h and q are
-/// OFF), so request headers, query string and body do not participate in the signature.
+/// The sidecar is invoked by its co-located application, so the call to /Validate is not the
+/// request the SHR was signed over; the caller must supply the original method and URI (they cannot be
+/// derived server-side). Only Uri and Method are required because validation covers the
+/// m/u/p/ts claims (h and q are off by default), so request
+/// headers, query string and body do not participate in the signature.
///
internal static class PopHttpRequestFactory
{
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs
index afa5885ec..103b22f52 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationResult.cs
@@ -6,7 +6,7 @@
namespace Microsoft.Identity.Web.Sidecar.Pop;
-/// SPIKE (throwaway): outcome of inbound SHR PoP validation.
+/// Outcome of inbound SHR PoP validation.
internal sealed class ShrPopValidationResult
{
private ShrPopValidationResult()
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
index 3a7283195..f511b3b8f 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
@@ -11,14 +11,12 @@
namespace Microsoft.Identity.Web.Sidecar.Pop;
///
-/// SPIKE (throwaway): validates an inbound Signed HTTP Request (SHR) Proof-of-Possession token by
-/// re-hosting MISE.AuthN's -based validation (see MISE
-/// PopTokenHandlerValidationRule + SignedHttpRequestValidationContextFactory).
-/// Scope: app-only tokens, timestamp-only (ts) validation. Server nonce is intentionally NOT wired.
+/// Validates an inbound Signed HTTP Request (SHR) Proof-of-Possession token. Scope: app-only (client-credentials)
+/// tokens with timestamp-only (ts) freshness. Server nonce is intentionally not wired.
///
internal sealed class ShrPopValidationService
{
- // SignedHttpRequestHandler is stateless and thread-safe; MISE also holds a single instance.
+ // SignedHttpRequestHandler is stateless and thread-safe, so a single shared instance is used.
private static readonly SignedHttpRequestHandler s_handler = new();
private readonly IOptionsMonitor _jwtBearerOptionsMonitor;
@@ -33,11 +31,10 @@ public ShrPopValidationService(
}
///
- /// Maps the operator-configurable onto Wilson's
- /// , mirroring MISE's
- /// SignedHttpRequestValidationContextFactory.CreateParametersFromOptions. With the options at
- /// their defaults (no Sidecar:PopValidation section) this reproduces the original hard-coded
- /// behavior: m/u/p/ts ON; q/h OFF; b OFF; unsigned headers/query accepted; 5-minute lifetime.
+ /// Maps the operator-configurable onto the identity model's
+ /// . With the options at their defaults (no
+ /// Sidecar:PopValidation section) this yields the secure default: m/u/p/ts on; q/h/b off;
+ /// unsigned headers/query accepted; five-minute lifetime.
///
internal static SignedHttpRequestValidationParameters CreateValidationParameters(PopValidationOptions options) => new()
{
@@ -48,8 +45,8 @@ public ShrPopValidationService(
ValidateH = options.ValidateH,
ValidateQ = options.ValidateQ,
- // Body-hash (b) stays OFF and is intentionally NOT operator-configurable: validating it requires
- // buffering/reading the request body, and MISE never surfaces it. See the design doc deferrals.
+ // Body-hash (b) stays off and is intentionally not operator-configurable: validating it requires
+ // buffering the request body, which the sidecar does not do.
ValidateB = false,
AcceptUnsignedHeaders = options.AcceptUnsignedHeaders,
@@ -57,9 +54,6 @@ public ShrPopValidationService(
ValidatePresentClaims = options.ValidatePresentClaims,
ClaimsToValidateWhenPresent = options.ClaimsToValidateWhenPresent,
SignedHttpRequestLifetime = options.SignedHttpRequestLifetime,
-
- // Server nonce is out of scope (timestamp-only): NonceValidatorAsync stays unset. jku key
- // resolution is left at Wilson defaults (AllowResolvingPopKeyFromJku = false) - no egress.
};
public async Task ValidateAsync(
@@ -67,30 +61,28 @@ public async Task ValidateAsync(
HttpRequest request,
CancellationToken cancellationToken)
{
- // (1) Request-line contract -> Wilson HttpRequestData (binds m/u/p).
+ // Request-line contract -> HttpRequestData (binds m/u/p).
if (!PopHttpRequestFactory.TryCreate(request, out var requestData, out var contractError))
{
return ShrPopValidationResult.Fail(contractError!);
}
- // (3) Reuse the SAME TokenValidationParameters JwtBearer built from AzureAd config - one source
- // of truth for issuer/audience/signing-keys. Clone so the shared instance is never mutated
- // per-request (mirrors MISE PopTokenTypeInfo.GetClonedValidationParameters).
+ // Reuse the TokenValidationParameters JwtBearer built from AzureAd config - one source of truth
+ // for issuer/audience/signing keys. Clone so the shared instance is never mutated per-request.
JwtBearerOptions bearerOptions =
_jwtBearerOptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme);
TokenValidationParameters? bearerTvp = bearerOptions.TokenValidationParameters;
if (bearerTvp is null)
{
- return ShrPopValidationResult.Fail("Bearer TokenValidationParameters are not configured.");
+ return ShrPopValidationResult.Fail("The Bearer scheme's TokenValidationParameters are not configured.");
}
TokenValidationParameters accessTokenValidationParameters = bearerTvp.Clone();
- // Ensure the cloned TVP can resolve the embedded AT's signing keys via live JWKS. In production
- // Microsoft.Identity.Web copies options.ConfigurationManager onto the shared TVP lazily, inside
- // the JwtBearer handler's OnMessageReceived event (IdentityOptionsHelpers.InitializeJwtBearerEvents),
- // which only fires when the JwtBearer scheme is authenticated first. Copying it explicitly here
- // removes that ordering/timing dependency so inner-AT signature validation always has a key source.
+ // Ensure the cloned TVP can resolve the embedded token's signing keys via live JWKS. The stock
+ // JwtBearer post-configure copies ConfigurationManager onto the shared TVP lazily inside its
+ // OnMessageReceived event, which only fires when the Bearer scheme authenticates first. Copying
+ // it here removes that ordering dependency; if it were ever null, inner-token validation fails closed.
accessTokenValidationParameters.ConfigurationManager ??=
bearerOptions.ConfigurationManager as BaseConfigurationManager;
@@ -107,9 +99,9 @@ public async Task ValidateAsync(
.ValidateSignedHttpRequestAsync(validationContext, cancellationToken)
.ConfigureAwait(false);
}
- catch (Exception ex)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
- // Wilson throws for malformed SHR/JWT input; treat as a validation failure.
+ // The identity model throws for malformed SHR/JWT input; treat as a validation failure.
return ShrPopValidationResult.Fail($"SHR validation threw: {ex.Message}");
}
@@ -118,8 +110,8 @@ public async Task ValidateAsync(
return ShrPopValidationResult.Fail(result.Exception?.Message ?? "SHR validation failed.");
}
- // The SHR handler validated BOTH the outer PoP signature AND the inner access token via the
- // reused TVP (issuer/audience/expiry/signing-key all enforced on the embedded AT here).
+ // The SHR handler validated both the outer PoP signature and the inner access token via the
+ // reused TVP (issuer/audience/expiry/signing key all enforced on the embedded token).
TokenValidationResult accessTokenResult = result.AccessTokenValidationResult;
if (accessTokenResult is null ||
!accessTokenResult.IsValid ||
@@ -129,12 +121,11 @@ accessTokenResult.SecurityToken is not JsonWebToken validatedAccessToken ||
return ShrPopValidationResult.Fail("Embedded access token validation failed.");
}
- // SPIKE (throwaway): enforce the declared app-only scope. SHR/inner-AT validation above proves
- // the embedded token is authentic but does NOT distinguish an app-only (client-credentials)
- // token from a delegated/user one. Without this guard a valid, cnf-bound *delegated* token
- // wrapped in an SHR would be accepted as PoP - and because the /Validate PoP branch skips the
- // delegated-scope (scp) gate, it would sail past the AzureAd:Scopes check a Bearer caller faces.
- // Reject anything that is not app-only; fail-closed (the handler logs the reason and 401s).
+ // Enforce app-only scope. SHR/inner-token validation proves the embedded token is authentic but
+ // does not distinguish an app-only (client-credentials) token from a delegated one. Without this
+ // guard a cnf-bound delegated token wrapped in an SHR would be accepted and, because the PoP path
+ // does not apply the delegated-scope (scp) gate, would bypass the AzureAd:Scopes check a Bearer
+ // caller faces. Reject anything that is not app-only; fail closed.
if (!IsAppOnlyToken(validatedAccessToken))
{
return ShrPopValidationResult.Fail(
@@ -145,13 +136,12 @@ accessTokenResult.SecurityToken is not JsonWebToken validatedAccessToken ||
}
///
- /// Distinguishes an app-only (client-credentials) token from a delegated/user token. Per Entra
- /// token semantics a delegated token carries the scp (scope) claim while an app-only token
- /// does not; the optional idtyp claim, when emitted, states this authoritatively
- /// ("app" vs "user"). A token is treated as app-only only when it has no scp
- /// AND (no idtyp, or idtyp == "app"). idtyp presence is not required because it
- /// is an optional claim many tenants do not emit. Entra always emits scp as a space-delimited
- /// string and the token is signed/validated, so a caller cannot evade this by reshaping the claim.
+ /// Distinguishes an app-only (client-credentials) token from a delegated/user token. A delegated
+ /// token carries the scp (scope) claim while an app-only token does not; the optional
+ /// idtyp claim, when emitted, states this authoritatively ("app" vs "user"). A
+ /// token is treated as app-only only when it has no scp AND (no idtyp, or
+ /// idtyp == "app"). idtyp is not required because it is an optional claim many tenants
+ /// do not emit. The token is signed and validated, so a caller cannot evade this by reshaping claims.
///
private static bool IsAppOnlyToken(JsonWebToken token)
{
diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs
index 695f5c0ed..88b9b3f66 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Program.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs
@@ -30,11 +30,7 @@ public static void Main(string[] args)
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
-
- // SPIKE (throwaway): the Bearer wiring below is UNCHANGED (still the default scheme).
- // AddInboundShrPop additively registers a second "PoP" authentication handler plus a named
- // authorization policy that only the /Validate endpoint opts into, so no other endpoint and
- // no global default is affected.
+ // AddInboundShrPop additively registers a second "PoP" authentication handler plus a named authorization policy that only the /Validate endpoint opts into.
var authenticationBuilder = builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
diff --git a/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs b/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs
index a2f0f0618..f3c119d21 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Properties/InternalsVisibleTo.cs
@@ -3,6 +3,6 @@
using System.Runtime.CompilerServices;
-// SPIKE (throwaway): expose internal SHR PoP mapping helpers (e.g. ShrPopValidationService's
+// Expose internal SHR PoP mapping helpers (e.g. ShrPopValidationService's
// DTO -> SignedHttpRequestValidationParameters mapping) to the sidecar test project for unit testing.
[assembly: InternalsVisibleTo("Sidecar.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")]
diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md
index b1996f31c..e4a868025 100644
--- a/src/Microsoft.Identity.Web.Sidecar/README.md
+++ b/src/Microsoft.Identity.Web.Sidecar/README.md
@@ -8,6 +8,7 @@ enables Microsoft Entra token acquisition and downstream API calls, and token va
### Key capabilities
- Validates incoming tokens and surfaces their claims.
+- Validates inbound app-only Proof-of-Possession (PoP) tokens (Signed HTTP Request) in addition to bearer tokens.
- Decrypts tokens if applicable.
- Acquires User OBO or Application tokens for configured downstream APIs.
@@ -66,6 +67,46 @@ Set it to `true` to opt in to following redirects.
> }
> ```
+### Inbound Proof-of-Possession (PoP) validation
+
+The `/Validate` endpoint also accepts a Signed HTTP Request (SHR) Proof-of-Possession credential using
+the `PoP` authorization scheme, in addition to `Bearer`. PoP validation is scoped to **app-only
+(client-credentials) tokens** and validates the embedded access token through the same `AzureAd`
+configuration (issuer, audience, signing keys) used for bearer validation. Bearer behavior is unchanged.
+
+Because the sidecar is invoked by its co-located application, the caller must supply the original
+request line the SHR was signed over via two headers:
+
+| Header | Description |
+| ----------------- | ----------------------------------------------- |
+| `original-method` | HTTP method of the signed request (e.g. `GET`). |
+| `original-uri` | Absolute URI of the signed request. |
+
+A validated PoP request returns `{ "protocol": "PoP", "token": "...", "claims": { ... } }`.
+
+Validation binds the `m` (method), `u` (host), `p` (path), and `ts` (timestamp) claims by default. The
+timestamp provides freshness within a five-minute window; server nonce and replay caching are not
+implemented. The parameters are operator-tunable under `Sidecar:PopValidation`:
+
+```jsonc
+{
+ "Sidecar": {
+ "PopValidation": {
+ "ValidateTs": true,
+ "ValidateM": true,
+ "ValidateU": true,
+ "ValidateP": true,
+ "ValidateQ": false,
+ "ValidateH": false,
+ "SignedHttpRequestLifetime": "00:05:00"
+ }
+ }
+}
+```
+
+See [`PopValidationOptions`](Configuration/PopValidationOptions.cs) for the full set of flags. When the
+section is absent, secure defaults apply.
+
## Running the sidecar
### Prerequisites
@@ -123,7 +164,7 @@ For all credential configuration options, see the [CredentialDescription documen
| Endpoint | Method | Auth | Description |
| ----------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ |
-| `/Validate` | GET | Required | Returns the raw bearer token and claims. Enforces `AzureAd:Scopes` when configured. |
+| `/Validate` | GET | Required | Returns the token and its claims. Accepts a `Bearer` or app-only `PoP` credential. Enforces `AzureAd:Scopes` when configured. |
| `/AuthorizationHeader/{apiName}` | GET | Required | Returns an `Authorization` header for the named downstream API using the caller’s identity. |
| `/AuthorizationHeaderUnauthenticated/{apiName}` | GET | Optional | Uses the sidecar’s application identity to obtain a token. |
| `/DownstreamApi/{apiName}` | POST | Required | Invokes the downstream API profile with the caller’s identity, forwarding body and content-type. |
@@ -156,18 +197,22 @@ Agent identity parameters are also subject to the per-route override flag:
- `/AuthorizationHeader*` returns `{ "authorizationHeader": "Bearer ey..." }`.
- `/DownstreamApi*` returns `{ "statusCode": 200, "headers": { ... }, "content": "..." }`.
-- `/Validate` returns `{ "protocol": "Bearer", "token": "ey...", "claims": { ... } }`.
+- `/Validate` returns `{ "protocol": "Bearer", "token": "ey...", "claims": { ... } }` (or `"protocol": "PoP"` for a validated PoP request).
## Security considerations
- This API is only for usage as a sidecar. This API should not be publicly callable as it
allows the caller to acquire tokens on behalf of the applications identity.
+- Inbound PoP binds to the `original-method`/`original-uri` headers supplied by the co-located
+ calling application; the sidecar does not independently observe the downstream request. The
+ `ts`-based freshness check (default five minutes) is not a replay cache, and server nonce is not
+ implemented.
## Runtime composition
| Concern | Implementation |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| Authentication & authorization | [`Program`](Program.cs) wires `AddMicrosoftIdentityWebApi`, optional scope enforcement, and agent identity overrides. |
+| Authentication & authorization | [`Program`](Program.cs) wires `AddMicrosoftIdentityWebApi`, optional scope enforcement, agent identity overrides, and additive inbound PoP validation ([`Pop`](Pop/)). |
| Endpoints | [`ValidateRequestEndpoints`](Endpoints/ValidateRequestEndpoints.cs), [`AuthorizationHeaderEndpoint`](Endpoints/AuthorizationHeaderEndpoint.cs), and [`DownstreamApiEndpoint`](Endpoints/DownstreamApiEndpoint.cs). |
| Downstream API | [`BindableDownstreamApiOptions`](Models/BindableDownstreamApiOptions.cs) merges per-request overrides into per call `DownstreamApis` configuration. |
| Agent Identities | [`AgentOverrides`](AgentOverrides.cs) binds agent identity, userPrincipalName, or user object ID when present and allowed by the per-route override flag. |
diff --git a/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs b/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs
index b715436ec..c4fde59d4 100644
--- a/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs
+++ b/tests/E2E Tests/Sidecar.Tests/MockIdpServer.cs
@@ -19,7 +19,7 @@
namespace Sidecar.Tests;
///
-/// SPIKE (throwaway): a self-contained mock Identity Provider (Entra/ESTS stand-in) hosted in-process
+/// A self-contained mock Identity Provider (Entra/ESTS stand-in) hosted in-process
/// on the ASP.NET Core . It reproduces the real-world validation surface so the
/// sidecar validates a PoP token's embedded access token through its GENUINE JwtBearer pipeline -
/// live OIDC discovery + JWKS fetch + signature/issuer/audience/lifetime checks - instead of a
diff --git a/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs b/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs
index 11efd6259..253eb572a 100644
--- a/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs
+++ b/tests/E2E Tests/Sidecar.Tests/PopSidecarApiFactory.cs
@@ -11,11 +11,11 @@
namespace Sidecar.Tests;
///
-/// SPIKE (throwaway): a sidecar test host that repoints the "Bearer" JwtBearerOptions at an in-process
+/// A sidecar test host that repoints the "Bearer" JwtBearerOptions at an in-process
/// (Entra/ESTS stand-in). The embedded access token inside an SHR is then
-/// validated through the sidecar's GENUINE JwtBearer pipeline - live OIDC discovery + JWKS fetch +
+/// validated through the sidecar's genuine JwtBearer pipeline - live OIDC discovery + JWKS fetch +
/// signature/issuer/audience/lifetime - with no statically injected signing key. This exercises the
-/// real-world validation path while proving objective 3: the PoP path reuses the SAME "Bearer" TVP
+/// real-world validation path and confirms the PoP path reuses the same "Bearer" TVP
/// (issuer/audience/expiry/signing key).
///
public class PopSidecarApiFactory : SidecarApiFactory
diff --git a/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs b/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
index fda03848f..8dbccf107 100644
--- a/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
+++ b/tests/E2E Tests/Sidecar.Tests/PopTestCrypto.cs
@@ -11,7 +11,7 @@
namespace Sidecar.Tests;
///
-/// SPIKE (throwaway) test helper: mints a self-contained ARM-shaped Signed HTTP Request (SHR) PoP
+/// Test helper that mints a self-contained ARM-shaped Signed HTTP Request (SHR) PoP
/// token without any live AAD dependency.
///
/// Two RSA keys are used, mirroring a real PoP flow:
diff --git a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
index 87fc43554..5e78829db 100644
--- a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
@@ -12,15 +12,15 @@
namespace Sidecar.Tests;
///
-/// SPIKE (throwaway): unit tests for the operator-configurable SHR PoP validation flags. Verifies that
-/// (a) with no Sidecar:PopValidation config the DTO ->
-/// mapping reproduces the original hard-coded MISE defaults, and (b) a bound config section overrides
-/// exactly the specified flags and nothing else (body-hash b stays OFF, server nonce stays unset).
+/// Unit tests for the operator-configurable SHR PoP validation flags. Verifies that (a) with no
+/// Sidecar:PopValidation config the DTO ->
+/// mapping reproduces the secure defaults, and (b) a bound config section overrides exactly the
+/// specified flags and nothing else (body-hash b stays off, server nonce stays unset).
///
public class PopValidationOptionsMappingTests
{
[Fact]
- public void CreateValidationParameters_WithDefaultOptions_MatchesOriginalHardcodedDefaults()
+ public void CreateValidationParameters_WithDefaultOptions_MatchesSecureDefaults()
{
// Arrange: a brand-new options object is exactly what binding produces when no
// Sidecar:PopValidation section is present.
@@ -79,7 +79,7 @@ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThos
Assert.Equal(TimeSpan.FromMinutes(2), parameters.SignedHttpRequestLifetime);
Assert.False(parameters.AcceptUnsignedHeaders);
- // ...and nothing else drifted from the MISE defaults.
+ // ...and nothing else drifted from the secure defaults.
Assert.True(parameters.ValidateM);
Assert.True(parameters.ValidateU);
Assert.True(parameters.ValidateP);
diff --git a/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs b/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
index 2e6f968ed..76de4d43b 100644
--- a/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/ShrPopValidationTests.cs
@@ -14,7 +14,7 @@
namespace Sidecar.Tests;
///
-/// SPIKE (throwaway): round-trip + negative tests for inbound SHR PoP validation in the sidecar.
+/// Round-trip and negative tests for inbound SHR PoP validation in the sidecar.
/// Positive: a valid ARM-shaped SHR (app-only AT, cnf-bound to a test key) returns 200 + Protocol "PoP".
/// Negatives: tampered method/uri, expired ts, bad signature, and (proving the reused AzureAd TVP)
/// wrong-issuer / expired embedded access token are all rejected.
@@ -181,7 +181,7 @@ public async Task Validate_WithBadSignature_ReturnsUnauthorizedAsync()
[Fact]
public async Task Validate_WithWrongIssuerEmbeddedToken_ReturnsUnauthorizedAsync()
{
- // Arrange (objective 3 evidence): embedded AT issuer is not the reused TVP's ValidIssuer.
+ // Arrange: embedded AT issuer is not the reused TVP's ValidIssuer.
string accessToken = PopTestCrypto.CreateAccessToken(
"https://sts.windows.net/evil-tenant/", PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(10),
PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
@@ -199,7 +199,7 @@ public async Task Validate_WithWrongIssuerEmbeddedToken_ReturnsUnauthorizedAsync
[Fact]
public async Task Validate_WithExpiredEmbeddedToken_ReturnsUnauthorizedAsync()
{
- // Arrange (objective 3 evidence): the SHR ts is fresh, but the embedded AT is expired, so the
+ // Arrange: the SHR ts is fresh, but the embedded AT is expired, so the
// reused TVP's lifetime validation must reject it.
string accessToken = PopTestCrypto.CreateAccessToken(
PopTestCrypto.TestIssuer, PopTestCrypto.TestAudience, DateTime.UtcNow.AddMinutes(-10),
@@ -301,8 +301,8 @@ public async Task Validate_WithValidBearerToken_Returns200_AndProtocolBearerAsyn
[Fact]
public async Task Validate_WithMalformedPopToken_ReturnsUnauthorized_NotServerErrorAsync()
{
- // Arrange: a non-JWT garbage string under the PoP scheme. The validator wraps Wilson in a
- // try/catch, so this must surface as a clean 401 - never a 500.
+ // Arrange: a non-JWT garbage string under the PoP scheme. The validator wraps the identity-model
+ // handler in a try/catch, so this must surface as a clean 401 - never a 500.
var client = _factory.CreateClient();
// Act
@@ -315,7 +315,7 @@ public async Task Validate_WithMalformedPopToken_ReturnsUnauthorized_NotServerEr
[Fact]
public async Task Validate_WithWrongAudienceEmbeddedToken_ReturnsUnauthorizedAsync()
{
- // Arrange (objective 3 evidence): embedded AT audience is not the reused TVP's ValidAudience.
+ // Arrange: embedded AT audience is not the reused TVP's ValidAudience.
string accessToken = PopTestCrypto.CreateAccessToken(
PopTestCrypto.TestIssuer, "api://wrong-audience", DateTime.UtcNow.AddMinutes(10),
PopTestCrypto.PopSigningKey, PopTestCrypto.PopKeyId);
@@ -390,7 +390,7 @@ public async Task Validate_WithInvalidPop_RecordsFailureReasonInServerLogsAsync(
{
// Arrange: capture server-side logs on an isolated host, then send a PoP request that fails
// validation (SHR signed by an untrusted key). JwtBearer logs TokenValidationFailed at
- // Information; the PoP path must likewise record the reason server-side (the design-doc claim)
+ // Information; the PoP path must likewise record the reason server-side
// even though the 401 challenge returned to the caller stays generic.
var logs = new CapturingLoggerProvider();
using var loggingFactory = _factory.WithWebHostBuilder(builder =>
@@ -434,7 +434,7 @@ private static HttpRequestMessage BuildPopRequest(string shr, string? originalUr
}
///
- /// SPIKE (throwaway): minimal in-memory that records every emitted
+ /// Minimal in-memory that records every emitted
/// log entry so a test can assert the PoP handler wrote its failure reason to the server-side log.
///
private sealed class CapturingLoggerProvider : ILoggerProvider
diff --git a/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj b/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj
index 997179435..9ac093fc4 100644
--- a/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj
+++ b/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs b/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs
index b498514ae..8ebbea9b0 100644
--- a/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/SidecarPopEndpointsE2ETests.cs
@@ -20,14 +20,13 @@
namespace Sidecar.Tests;
///
-/// SPIKE (throwaway) END-TO-END pipeline tests for inbound SHR PoP validation - the design-doc §9
-/// "E2E (pipeline)" deliverable. Unlike (which mints tokens from an
-/// in-process mock IdP), these exercise the WHOLE real path: a REAL app-only token is acquired from
-/// Entra, cnf-bound and wrapped in an ARM-shaped Signed HTTP Request by MSAL (via MSIdWeb's
-/// WithSignedHttpRequestProofOfPossession), then validated by the sidecar through its REAL AzureAd
-/// discovery/JWKS path (configured on ).
+/// End-to-end pipeline tests for inbound SHR PoP validation. Unlike
+/// (which mints tokens from an in-process mock IdP), these exercise the whole real path: a real app-only
+/// token is acquired from Entra, cnf-bound and wrapped in an ARM-shaped Signed HTTP Request by MSAL (via
+/// Microsoft.Identity.Web's WithSignedHttpRequestProofOfPossession), then validated by the sidecar
+/// through its real AzureAd discovery/JWKS path (configured on ).
///
-/// Coverage mirrors the Bearer E2E surface ( +
+/// Coverage mirrors the Bearer surface ( +
/// ) and adds the PoP-specific guarantees that only a real
/// token can prove end-to-end:
/// - Happy paths: real PoP → Protocol "PoP"; real Bearer control → Protocol "Bearer".
@@ -56,7 +55,7 @@ public class SidecarPopEndpointsE2ETests : IClassFixture
const string AgentApplication = "aab5089d-e764-47e3-9f28-cc11c2513821"; // Sidecar's AzureAd ClientId/Audience (see SidecarApiFactory)
const string TestClientApplication = "825940df-c1fb-4604-8104-02965f55b1ee"; // Replace with the client used for app-only calls
const string Instance = "https://login.microsoftonline.com/"; // Entra ID authority instance
- const string CertificateStorePath = "CurrentUser/My"; // Local dev: CurrentUser avoids the LocalMachine private-key ACL/elevation requirement (pipeline may use LocalMachine/My)
+ const string CertificateStorePath = "LocalMachine/My"; // Replace with the certificate store path (local runs without elevation may use CurrentUser/My)
const string CertificateDistinguishedName = "CN=LabAuth.MSIDLab.com"; // Replace with the certificate subject name
static readonly string AgentApplicationScope = $"api://{AgentApplication}/.default";
@@ -197,7 +196,7 @@ public async Task Validate_RealEntraBearerToken_PresentedOnPopScheme_ReturnsUnau
}
// ---------------------------------------------------------------------------------------------
- // No credential (mirrors the Bearer no-auth E2E test + design-doc "no credential -> advertise Bearer")
+ // No credential: mirror the Bearer E2E test (no credential -> advertise Bearer).
// ---------------------------------------------------------------------------------------------
[Fact]
@@ -247,7 +246,7 @@ private async Task SendToValidateAsync(
var request = new HttpRequestMessage(HttpMethod.Get, "/Validate");
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, credential);
- // Request-line contract headers (identical to the MISE container: original-method / original-uri).
+ // Request-line contract headers: original-method / original-uri.
// The app supplies these because /Validate receives a validation request, not the signed request.
if (originalMethod is not null)
{
From 3e0c510502d2a54ff863672874ef08ac836da14f Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Fri, 28 Aug 2026 17:06:12 +0100
Subject: [PATCH 5/9] Document inbound PoP (SHR) validation in the sidecar and
add sample clients
Update the sidecar documentation surface for inbound app-only PoP (Signed HTTP
Request) validation on /Validate, and extend the dev-adapter sample clients to
exercise the PoP path alongside Bearer.
- README: note the WWW-Authenticate: PoP invalid_token challenge on PoP failure.
- appsettings.json: add a commented Sidecar:PopValidation sample block.
- PopValidationOptions / ValidateAuthorizationHeaderResult: clarify XML docs and
document the Protocol values (Bearer or PoP).
- .http: add a PoP /Validate sample request with original-method/original-uri.
- Python sample client + CLI: accept optional original-method/original-uri and
document a PoP validate example.
- TypeScript sample: forward the request line for PoP tokens and document it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Configuration/PopValidationOptions.cs | 5 +++--
.../Microsoft.Identity.Web.Sidecar.http | 10 +++++++++
.../ValidateAuthorizationHeaderResult.cs | 4 ++--
src/Microsoft.Identity.Web.Sidecar/README.md | 3 ++-
.../appsettings.json | 13 ++++++++++++
.../MicrosoftIdentityWebSidecarClient.py | 21 +++++++++++++++++--
tests/DevApps/SidecarAdapter/python/README.md | 6 ++++++
tests/DevApps/SidecarAdapter/python/main.py | 21 +++++++++++++++++--
.../SidecarAdapter/typescript/README.md | 14 +++++++++++++
.../SidecarAdapter/typescript/src/app.ts | 9 ++++++++
10 files changed, 97 insertions(+), 9 deletions(-)
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
index 5e9ad7c94..8bd97cf9d 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
@@ -7,13 +7,14 @@
namespace Microsoft.Identity.Web.Sidecar.Configuration;
///
-/// tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession validation, bound
+/// Tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession validation, bound
/// from the Sidecar:PopValidation configuration subsection. Every property defaults to the
/// secure Microsoft.IdentityModel SignedHttpRequestValidationParameters default, so when the
/// subsection is absent PoP validation runs with method/URI/path/timestamp binding on, query/header
/// binding off, unsigned headers and query parameters accepted, and a five-minute lifetime.
///
-/// Only the members exposed as simple configuration values are surfaced here. Body-hash (b)
+/// Only members exposed as simple configuration values are surfaced here; body-hash (b)
+/// binding is not configurable and remains off.
///
///
public class PopValidationOptions
diff --git a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http
index a3f599538..a7c22eb76 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http
+++ b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http
@@ -1,6 +1,7 @@
@Microsoft.Identity.Web.Sidecar_HostAddress = http://localhost:5178
@AccessToken =
+@PopToken =
@ApiName =
###
@@ -36,3 +37,12 @@ Authorization: Bearer {{AccessToken}}
POST {{Microsoft.Identity.Web.Sidecar_HostAddress}}/DownstreamApiUnauthenticated/{{ApiName}}
Accept: application/json
+
+###
+
+# Inbound PoP (Signed HTTP Request) validation for an app-only token. The caller supplies the
+# original request line the SHR was signed over via the original-method / original-uri headers.
+GET {{Microsoft.Identity.Web.Sidecar_HostAddress}}/Validate
+Authorization: PoP {{PopToken}}
+original-method: GET
+original-uri: https://api.contoso.com/data
diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs b/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs
index 307df1311..d8a7585f5 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs
@@ -6,9 +6,9 @@
namespace Microsoft.Identity.Web.Sidecar.Models;
///
-/// The result of validation an authorization header.
+/// The result of validating an authorization header.
///
-/// The protocol.
+/// The authentication protocol the credential was validated with: Bearer or PoP.
/// The token validated.
/// The claims parsed from the token.
public record ValidateAuthorizationHeaderResult(string Protocol, string Token, JsonNode Claims);
diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md
index e4a868025..45fa04856 100644
--- a/src/Microsoft.Identity.Web.Sidecar/README.md
+++ b/src/Microsoft.Identity.Web.Sidecar/README.md
@@ -82,7 +82,8 @@ request line the SHR was signed over via two headers:
| `original-method` | HTTP method of the signed request (e.g. `GET`). |
| `original-uri` | Absolute URI of the signed request. |
-A validated PoP request returns `{ "protocol": "PoP", "token": "...", "claims": { ... } }`.
+A validated PoP request returns `{ "protocol": "PoP", "token": "...", "claims": { ... } }`. A failed
+PoP credential returns `401` with a `WWW-Authenticate: PoP error="invalid_token"` challenge.
Validation binds the `m` (method), `u` (host), `p` (path), and `ts` (timestamp) claims by default. The
timestamp provides freshness within a five-minute window; server nonce and replay caching are not
diff --git a/src/Microsoft.Identity.Web.Sidecar/appsettings.json b/src/Microsoft.Identity.Web.Sidecar/appsettings.json
index 8a0fed809..c584d71c4 100644
--- a/src/Microsoft.Identity.Web.Sidecar/appsettings.json
+++ b/src/Microsoft.Identity.Web.Sidecar/appsettings.json
@@ -38,6 +38,19 @@
// "GetAuthorizationHeaderUnauthenticated": false,
// "CallDownstreamApi": true,
// "CallDownstreamApiUnauthenticated": false
+ // },
+ // // Inbound Signed HTTP Request (PoP) validation for app-only tokens.
+ // // All flags are optional; shown here with their secure defaults.
+ // "PopValidation": {
+ // "ValidateTs": true,
+ // "ValidateM": true,
+ // "ValidateU": true,
+ // "ValidateP": true,
+ // "ValidateQ": false,
+ // "ValidateH": false,
+ // "AcceptUnsignedHeaders": true,
+ // "AcceptUnsignedQueryParameters": true,
+ // "SignedHttpRequestLifetime": "00:05:00"
// }
//},
diff --git a/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py b/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py
index 83da2a94e..8339065fc 100644
--- a/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py
+++ b/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py
@@ -130,11 +130,28 @@ def __enter__(self) -> "MicrosoftIdentityWebSidecarClient":
def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override]
self.close()
- def validate_authorization_header(self, authorization_header: str) -> ValidateAuthorizationHeaderResult:
+ def validate_authorization_header(
+ self,
+ authorization_header: str,
+ *,
+ original_method: Optional[str] = None,
+ original_uri: Optional[str] = None,
+ ) -> ValidateAuthorizationHeaderResult:
+ """Validate a Bearer or PoP (SHR) authorization header via the /Validate endpoint.
+
+ For PoP (Signed HTTP Request) tokens, supply the signed request line via
+ ``original_method`` and ``original_uri`` so the sidecar can bind the signature to
+ the request. Both are ignored for Bearer tokens.
+ """
+ headers: Dict[str, str] = {"Authorization": authorization_header}
+ if original_method:
+ headers["original-method"] = original_method
+ if original_uri:
+ headers["original-uri"] = original_uri
response_data = self._send_json(
method="GET",
path="Validate",
- headers={"Authorization": authorization_header},
+ headers=headers,
)
return ValidateAuthorizationHeaderResult.from_dict(response_data)
diff --git a/tests/DevApps/SidecarAdapter/python/README.md b/tests/DevApps/SidecarAdapter/python/README.md
index 0f4d23cba..e2f4f68f7 100644
--- a/tests/DevApps/SidecarAdapter/python/README.md
+++ b/tests/DevApps/SidecarAdapter/python/README.md
@@ -35,6 +35,12 @@ Example: validate an authorization header returned by `get_token.py`:
uv run --with requests main.py --base-url $side_car_url --authorization-header "Bearer $token" validate
```
+Example: validate a PoP (Signed HTTP Request) app-only token, supplying the signed request line:
+
+```sh
+uv run --with requests main.py --base-url $side_car_url --authorization-header "PoP " validate --original-method GET --original-uri "https://api.contoso.com/data"
+```
+
Invoke a downstream API by name, supplying an override scope and a JSON payload stored in `body.json`:
```sh
diff --git a/tests/DevApps/SidecarAdapter/python/main.py b/tests/DevApps/SidecarAdapter/python/main.py
index 786610f46..63db17434 100644
--- a/tests/DevApps/SidecarAdapter/python/main.py
+++ b/tests/DevApps/SidecarAdapter/python/main.py
@@ -39,7 +39,20 @@ def parse_args() -> argparse.Namespace:
subparsers = parser.add_subparsers(dest="command", required=True)
- subparsers.add_parser("validate", help="Validate an authorization header using the /Validate endpoint.")
+ validate_parser = subparsers.add_parser(
+ "validate",
+ help="Validate a Bearer or PoP (SHR) authorization header using the /Validate endpoint.",
+ )
+ validate_parser.add_argument(
+ "--original-method",
+ dest="original_method",
+ help="For PoP (SHR) tokens: the signed HTTP method, sent as the 'original-method' header.",
+ )
+ validate_parser.add_argument(
+ "--original-uri",
+ dest="original_uri",
+ help="For PoP (SHR) tokens: the signed absolute request URI, sent as the 'original-uri' header.",
+ )
auth_header_parser = subparsers.add_parser(
"get-auth-header",
@@ -261,7 +274,11 @@ def main() -> None:
with MicrosoftIdentityWebSidecarClient(args.base_url) as client:
if args.command == "validate":
authorization_header = ensure_authorization_header(args)
- result = client.validate_authorization_header(authorization_header)
+ result = client.validate_authorization_header(
+ authorization_header,
+ original_method=getattr(args, "original_method", None),
+ original_uri=getattr(args, "original_uri", None),
+ )
_print_json({
"protocol": result.protocol,
"token": result.token,
diff --git a/tests/DevApps/SidecarAdapter/typescript/README.md b/tests/DevApps/SidecarAdapter/typescript/README.md
index 260ad2d76..db1e17b3c 100644
--- a/tests/DevApps/SidecarAdapter/typescript/README.md
+++ b/tests/DevApps/SidecarAdapter/typescript/README.md
@@ -30,3 +30,17 @@ npm test
This is an integration style test. Make sure the sidecar is running.
This will use msal-node to acquire a token and call the sample server application which will call the sidecar.
+
+## Proof-of-Possession (PoP) tokens
+
+The sample server validates both `Bearer` and `PoP` (Signed HTTP Request) tokens. When the inbound `Authorization` header uses the `PoP` scheme, `app.ts` forwards the request line to the sidecar via the `original-method` and `original-uri` headers so the SHR signature can be bound to the request. `SidecarClient` accepts arbitrary headers through the `headers` option:
+
+```ts
+await sidecarClient.validateAuthorizationHeader({
+ authorizationHeader: `PoP ${shrToken}`,
+ headers: {
+ 'original-method': 'GET',
+ 'original-uri': 'https://api.contoso.com/data',
+ },
+});
+```
diff --git a/tests/DevApps/SidecarAdapter/typescript/src/app.ts b/tests/DevApps/SidecarAdapter/typescript/src/app.ts
index 2c4b9cfd3..14ba1059e 100644
--- a/tests/DevApps/SidecarAdapter/typescript/src/app.ts
+++ b/tests/DevApps/SidecarAdapter/typescript/src/app.ts
@@ -42,6 +42,15 @@ export const startServer = async ({
try {
const validation = await sidecarClient.validateAuthorizationHeader({
authorizationHeader: authorization,
+ // PoP (SHR) tokens are bound to the request line; forward it so the sidecar can verify the signature. Bearer needs no extra headers.
+ ...(authorization.startsWith('PoP ')
+ ? {
+ headers: {
+ 'original-method': req.method,
+ 'original-uri': `${req.protocol}://${req.get('host')}${req.originalUrl}`,
+ },
+ }
+ : {}),
});
console.log('Sidecar validation successful');
From 73ad1be058760438ffc8acc5a39d4c2a3f4cbcf5 Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Tue, 1 Sep 2026 16:21:18 +0100
Subject: [PATCH 6/9] Remove non-functional PoP header-binding knobs from the
sidecar
The sidecar receives only the request line (method + URI) from the caller,
never the SHR-signed headers, so the h claim can never be validated against
them. ValidateH/AcceptUnsignedHeaders were therefore removed from the
Sidecar:PopValidation surface; the secure defaults (h binding off, unsigned
headers accepted) are now hardcoded in the validation parameters. Query (q)
binding stays configurable because the query rides inside original-uri.
Updates the options doc-comments, README and appsettings sample, and reframes
the mapping test to prove the removed keys are now inert. Addresses PR #4008
review feedback.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Configuration/PopValidationOptions.cs | 24 ++++---------------
.../Pop/PopHttpRequestFactory.cs | 7 +++---
.../Pop/ShrPopValidationService.cs | 9 +++----
src/Microsoft.Identity.Web.Sidecar/README.md | 1 -
.../appsettings.json | 2 --
.../PopValidationOptionsMappingTests.cs | 15 ++++++++----
6 files changed, 25 insertions(+), 33 deletions(-)
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
index 8bd97cf9d..eae2c9301 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
@@ -10,11 +10,12 @@ namespace Microsoft.Identity.Web.Sidecar.Configuration;
/// Tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession validation, bound
/// from the Sidecar:PopValidation configuration subsection. Every property defaults to the
/// secure Microsoft.IdentityModel SignedHttpRequestValidationParameters default, so when the
-/// subsection is absent PoP validation runs with method/URI/path/timestamp binding on, query/header
-/// binding off, unsigned headers and query parameters accepted, and a five-minute lifetime.
+/// subsection is absent PoP validation runs with method/URI/path/timestamp binding on, query
+/// binding off, unsigned query parameters accepted, and a five-minute lifetime.
///
-/// Only members exposed as simple configuration values are surfaced here; body-hash (b)
-/// binding is not configurable and remains off.
+/// Only members exposed as simple configuration values are surfaced here. Header (h) and
+/// body-hash (b) binding are not configurable: the sidecar receives only the request line
+/// (method and URI), so header binding stays off and unsigned headers are accepted.
///
///
public class PopValidationOptions
@@ -55,21 +56,6 @@ public class PopValidationOptions
[DefaultValue(false)]
public bool ValidateQ { get; set; }
- ///
- /// Gets or sets a value indicating whether the h (headers) claim is validated - request
- /// binding. Default false; enabling it hardens binding but the caller must sign h or
- /// every request fails.
- ///
- [DefaultValue(false)]
- public bool ValidateH { get; set; }
-
- ///
- /// Gets or sets a value indicating whether headers not covered by the signature are accepted.
- /// Default true.
- ///
- [DefaultValue(true)]
- public bool AcceptUnsignedHeaders { get; set; } = true;
-
///
/// Gets or sets a value indicating whether query parameters not covered by the signature are
/// accepted. Default true.
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
index f7df4fdcd..20bc9f68b 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopHttpRequestFactory.cs
@@ -12,9 +12,10 @@ namespace Microsoft.Identity.Web.Sidecar.Pop;
///
/// The sidecar is invoked by its co-located application, so the call to /Validate is not the
/// request the SHR was signed over; the caller must supply the original method and URI (they cannot be
-/// derived server-side). Only Uri and Method are required because validation covers the
-/// m/u/p/ts claims (h and q are off by default), so request
-/// headers, query string and body do not participate in the signature.
+/// derived server-side). Only Uri and Method are populated: header (h) and body
+/// (b) binding are not supported (the sidecar never receives the signed headers or body), and
+/// query (q) binding, when enabled, is validated from the query component of the absolute
+/// original-uri. So request headers and body do not participate in validation.
///
internal static class PopHttpRequestFactory
{
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
index f511b3b8f..2f689c061 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
@@ -42,14 +42,15 @@ public ShrPopValidationService(
ValidateU = options.ValidateU,
ValidateP = options.ValidateP,
ValidateTs = options.ValidateTs,
- ValidateH = options.ValidateH,
ValidateQ = options.ValidateQ,
- // Body-hash (b) stays off and is intentionally not operator-configurable: validating it requires
- // buffering the request body, which the sidecar does not do.
+ // Header (h) and body-hash (b) binding are intentionally not configurable: the sidecar
+ // receives only the request line (method + URI), not the signed headers or body, so h and b stay
+ // off and unsigned headers are accepted.
+ ValidateH = false,
ValidateB = false,
+ AcceptUnsignedHeaders = true,
- AcceptUnsignedHeaders = options.AcceptUnsignedHeaders,
AcceptUnsignedQueryParameters = options.AcceptUnsignedQueryParameters,
ValidatePresentClaims = options.ValidatePresentClaims,
ClaimsToValidateWhenPresent = options.ClaimsToValidateWhenPresent,
diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md
index 45fa04856..4a85a5518 100644
--- a/src/Microsoft.Identity.Web.Sidecar/README.md
+++ b/src/Microsoft.Identity.Web.Sidecar/README.md
@@ -98,7 +98,6 @@ implemented. The parameters are operator-tunable under `Sidecar:PopValidation`:
"ValidateU": true,
"ValidateP": true,
"ValidateQ": false,
- "ValidateH": false,
"SignedHttpRequestLifetime": "00:05:00"
}
}
diff --git a/src/Microsoft.Identity.Web.Sidecar/appsettings.json b/src/Microsoft.Identity.Web.Sidecar/appsettings.json
index c584d71c4..13e87dab3 100644
--- a/src/Microsoft.Identity.Web.Sidecar/appsettings.json
+++ b/src/Microsoft.Identity.Web.Sidecar/appsettings.json
@@ -47,8 +47,6 @@
// "ValidateU": true,
// "ValidateP": true,
// "ValidateQ": false,
- // "ValidateH": false,
- // "AcceptUnsignedHeaders": true,
// "AcceptUnsignedQueryParameters": true,
// "SignedHttpRequestLifetime": "00:05:00"
// }
diff --git a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
index 5e78829db..b8e5719c9 100644
--- a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
@@ -60,8 +60,12 @@ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThos
.AddInMemoryCollection(new Dictionary
{
["Sidecar:PopValidation:ValidateQ"] = "true",
- ["Sidecar:PopValidation:ValidateH"] = "true",
["Sidecar:PopValidation:SignedHttpRequestLifetime"] = "00:02:00",
+
+ // Header binding is not a supported option: the sidecar receives only the request
+ // line, never the signed headers. These keys are set to insecure values to prove
+ // they have no effect and cannot enable header binding.
+ ["Sidecar:PopValidation:ValidateH"] = "true",
["Sidecar:PopValidation:AcceptUnsignedHeaders"] = "false",
})
.Build();
@@ -73,11 +77,14 @@ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThos
SignedHttpRequestValidationParameters parameters =
ShrPopValidationService.CreateValidationParameters(sidecarOptions.PopValidation);
- // Assert: exactly the four overrides took effect...
+ // Assert: exactly the two supported overrides took effect...
Assert.True(parameters.ValidateQ);
- Assert.True(parameters.ValidateH);
Assert.Equal(TimeSpan.FromMinutes(2), parameters.SignedHttpRequestLifetime);
- Assert.False(parameters.AcceptUnsignedHeaders);
+
+ // ...header binding is not operator-configurable (only the request line reaches the
+ // sidecar), so it stays off regardless of the keys set above...
+ Assert.False(parameters.ValidateH);
+ Assert.True(parameters.AcceptUnsignedHeaders);
// ...and nothing else drifted from the secure defaults.
Assert.True(parameters.ValidateM);
From c6a59d469abb397e7b684a6c33d7d6d32bff69a2 Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:21:51 +0100
Subject: [PATCH 7/9] Make PoP timestamp validation always-on in the sidecar
ValidateTs is removed from the Sidecar:PopValidation surface and hardcoded to
true. Timestamp validation is the only replay/freshness guard in the nonce-less
design, so it must stay on and there is no reason to expose it. Updates the
options doc-comment, the README and appsettings sample, and the mapping test,
which now proves a ValidateTs=false config key is ignored. Addresses PR #4008
review feedback.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Configuration/PopValidationOptions.cs | 11 +++--------
.../Pop/ShrPopValidationService.cs | 4 +++-
src/Microsoft.Identity.Web.Sidecar/README.md | 1 -
.../appsettings.json | 1 -
.../PopValidationOptionsMappingTests.cs | 14 ++++++++------
5 files changed, 14 insertions(+), 17 deletions(-)
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
index eae2c9301..72e7fdc5b 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs
@@ -15,18 +15,13 @@ namespace Microsoft.Identity.Web.Sidecar.Configuration;
///
/// Only members exposed as simple configuration values are surfaced here. Header (h) and
/// body-hash (b) binding are not configurable: the sidecar receives only the request line
-/// (method and URI), so header binding stays off and unsigned headers are accepted.
+/// (method and URI), so header binding stays off and unsigned headers are accepted. Timestamp
+/// (ts) validation is likewise not configurable and always on - it is the sole
+/// replay/freshness guard in this nonce-less design.
///
///
public class PopValidationOptions
{
- ///
- /// Gets or sets a value indicating whether the ts (timestamp) claim is validated - the
- /// replay/freshness guard. Default true; disabling it removes replay protection.
- ///
- [DefaultValue(true)]
- public bool ValidateTs { get; set; } = true;
-
///
/// Gets or sets a value indicating whether the m (HTTP method) claim is validated - request
/// binding. Default true; disabling it lets a captured SHR be replayed against another method.
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
index 2f689c061..eb96e5f55 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
@@ -41,9 +41,11 @@ public ShrPopValidationService(
ValidateM = options.ValidateM,
ValidateU = options.ValidateU,
ValidateP = options.ValidateP,
- ValidateTs = options.ValidateTs,
ValidateQ = options.ValidateQ,
+ // Timestamp (ts) validation is always on as the only replay/freshness guard in the nonce-less design.
+ ValidateTs = true,
+
// Header (h) and body-hash (b) binding are intentionally not configurable: the sidecar
// receives only the request line (method + URI), not the signed headers or body, so h and b stay
// off and unsigned headers are accepted.
diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md
index 4a85a5518..0dfd16df6 100644
--- a/src/Microsoft.Identity.Web.Sidecar/README.md
+++ b/src/Microsoft.Identity.Web.Sidecar/README.md
@@ -93,7 +93,6 @@ implemented. The parameters are operator-tunable under `Sidecar:PopValidation`:
{
"Sidecar": {
"PopValidation": {
- "ValidateTs": true,
"ValidateM": true,
"ValidateU": true,
"ValidateP": true,
diff --git a/src/Microsoft.Identity.Web.Sidecar/appsettings.json b/src/Microsoft.Identity.Web.Sidecar/appsettings.json
index 13e87dab3..50d944ff6 100644
--- a/src/Microsoft.Identity.Web.Sidecar/appsettings.json
+++ b/src/Microsoft.Identity.Web.Sidecar/appsettings.json
@@ -42,7 +42,6 @@
// // Inbound Signed HTTP Request (PoP) validation for app-only tokens.
// // All flags are optional; shown here with their secure defaults.
// "PopValidation": {
- // "ValidateTs": true,
// "ValidateM": true,
// "ValidateU": true,
// "ValidateP": true,
diff --git a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
index b8e5719c9..90be91b6c 100644
--- a/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
+++ b/tests/E2E Tests/Sidecar.Tests/PopValidationOptionsMappingTests.cs
@@ -62,9 +62,11 @@ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThos
["Sidecar:PopValidation:ValidateQ"] = "true",
["Sidecar:PopValidation:SignedHttpRequestLifetime"] = "00:02:00",
- // Header binding is not a supported option: the sidecar receives only the request
- // line, never the signed headers. These keys are set to insecure values to prove
- // they have no effect and cannot enable header binding.
+ // Timestamp and header binding are not supported configuration options: timestamp
+ // validation is always on, and the sidecar receives only the request line, never the
+ // signed headers. The values below would weaken validation if applied, confirming the
+ // mapping ignores them.
+ ["Sidecar:PopValidation:ValidateTs"] = "false",
["Sidecar:PopValidation:ValidateH"] = "true",
["Sidecar:PopValidation:AcceptUnsignedHeaders"] = "false",
})
@@ -81,8 +83,9 @@ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThos
Assert.True(parameters.ValidateQ);
Assert.Equal(TimeSpan.FromMinutes(2), parameters.SignedHttpRequestLifetime);
- // ...header binding is not operator-configurable (only the request line reaches the
- // sidecar), so it stays off regardless of the keys set above...
+ // ...timestamp validation and header binding are not operator-configurable, so they keep
+ // their secure values regardless of the keys set above...
+ Assert.True(parameters.ValidateTs);
Assert.False(parameters.ValidateH);
Assert.True(parameters.AcceptUnsignedHeaders);
@@ -90,7 +93,6 @@ public void CreateValidationParameters_WithBoundConfigOverrides_ReflectsOnlyThos
Assert.True(parameters.ValidateM);
Assert.True(parameters.ValidateU);
Assert.True(parameters.ValidateP);
- Assert.True(parameters.ValidateTs);
Assert.True(parameters.AcceptUnsignedQueryParameters);
Assert.False(parameters.ValidatePresentClaims);
Assert.Equal(new[] { "m", "p" }, parameters.ClaimsToValidateWhenPresent);
From 2a109f529efb5f5a3af0f33609bb310ed3b1ce38 Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:49:18 +0100
Subject: [PATCH 8/9] Validate sidecar PoP options at startup and harden the
validation path
Add an IValidateOptions validator that bounds
SignedHttpRequestLifetime greater than zero and register it with
ValidateOnStart, so a misconfigured lifetime fails the host boot instead of
throwing on the first PoP request. Also move the
SignedHttpRequestValidationContext construction inside the guarded try in
ValidateAsync, so any residual throw returns a clean 401 rather than a 500.
Adds unit tests for the validator. Addresses PR #4008 review feedback.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Configuration/ValidateSidecarOptions.cs | 27 ++++++++
.../Pop/PopAuthenticationExtensions.cs | 7 +++
.../Pop/ShrPopValidationService.cs | 15 ++---
.../ValidateSidecarOptionsTests.cs | 63 +++++++++++++++++++
4 files changed, 105 insertions(+), 7 deletions(-)
create mode 100644 src/Microsoft.Identity.Web.Sidecar/Configuration/ValidateSidecarOptions.cs
create mode 100644 tests/E2E Tests/Sidecar.Tests/ValidateSidecarOptionsTests.cs
diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/ValidateSidecarOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/ValidateSidecarOptions.cs
new file mode 100644
index 000000000..9a66f8ab9
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/ValidateSidecarOptions.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Identity.Web.Sidecar.Configuration;
+
+///
+/// Validates at startup. Registered with ValidateOnStart so an
+/// invalid value fails the host boot instead of surfacing as a per-request failure.
+///
+internal sealed class ValidateSidecarOptions : IValidateOptions
+{
+ public ValidateOptionsResult Validate(string? name, SidecarOptions options)
+ {
+ // The identity model rejects a non-positive SignedHttpRequestLifetime when the validation
+ // parameters are built, which would otherwise throw on the first PoP request. Bound it here so
+ // the misconfiguration is caught at boot.
+ if (options.PopValidation.SignedHttpRequestLifetime <= TimeSpan.Zero)
+ {
+ return ValidateOptionsResult.Fail(
+ "Sidecar:PopValidation:SignedHttpRequestLifetime must be greater than zero.");
+ }
+
+ return ValidateOptionsResult.Success;
+ }
+}
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
index 66c021abb..20584aad0 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/PopAuthenticationExtensions.cs
@@ -5,6 +5,8 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Web.Sidecar.Configuration;
namespace Microsoft.Identity.Web.Sidecar.Pop;
@@ -25,6 +27,11 @@ public static AuthenticationBuilder AddInboundShrPop(this AuthenticationBuilder
{
builder.Services.AddSingleton();
+ // Fail fast at startup on an invalid Sidecar configuration (e.g. a non-positive PoP lifetime)
+ // instead of throwing on the first PoP request.
+ builder.Services.AddSingleton, ValidateSidecarOptions>();
+ builder.Services.AddOptions().ValidateOnStart();
+
builder.AddScheme(
PopConstants.SchemeName,
static _ => { });
diff --git a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
index eb96e5f55..04f69cadf 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs
@@ -89,22 +89,23 @@ public async Task ValidateAsync(
accessTokenValidationParameters.ConfigurationManager ??=
bearerOptions.ConfigurationManager as BaseConfigurationManager;
- var validationContext = new SignedHttpRequestValidationContext(
- encodedSignedHttpRequest,
- requestData,
- accessTokenValidationParameters,
- CreateValidationParameters(_sidecarOptionsMonitor.CurrentValue.PopValidation));
-
SignedHttpRequestValidationResult result;
try
{
+ var validationContext = new SignedHttpRequestValidationContext(
+ encodedSignedHttpRequest,
+ requestData,
+ accessTokenValidationParameters,
+ CreateValidationParameters(_sidecarOptionsMonitor.CurrentValue.PopValidation));
+
result = await s_handler
.ValidateSignedHttpRequestAsync(validationContext, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
- // The identity model throws for malformed SHR/JWT input; treat as a validation failure.
+ // The identity model throws for malformed SHR/JWT input or invalid validation parameters;
+ // treat as a validation failure so the request fails closed with a 401 rather than a 500.
return ShrPopValidationResult.Fail($"SHR validation threw: {ex.Message}");
}
diff --git a/tests/E2E Tests/Sidecar.Tests/ValidateSidecarOptionsTests.cs b/tests/E2E Tests/Sidecar.Tests/ValidateSidecarOptionsTests.cs
new file mode 100644
index 000000000..9ab89e688
--- /dev/null
+++ b/tests/E2E Tests/Sidecar.Tests/ValidateSidecarOptionsTests.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Web.Sidecar.Configuration;
+using Xunit;
+
+namespace Sidecar.Tests;
+
+///
+/// Unit tests for , the startup validator that bounds the PoP
+/// SignedHttpRequestLifetime so a misconfiguration fails the host boot instead of every PoP request.
+///
+public class ValidateSidecarOptionsTests
+{
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(-3600)]
+ public void Validate_WithNonPositiveLifetime_Fails(int seconds)
+ {
+ // Arrange
+ var options = new SidecarOptions();
+ options.PopValidation.SignedHttpRequestLifetime = TimeSpan.FromSeconds(seconds);
+
+ // Act
+ ValidateOptionsResult result = new ValidateSidecarOptions().Validate(name: null, options);
+
+ // Assert
+ Assert.True(result.Failed);
+ Assert.Contains("SignedHttpRequestLifetime", result.FailureMessage!, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(300)]
+ public void Validate_WithPositiveLifetime_Succeeds(int seconds)
+ {
+ // Arrange
+ var options = new SidecarOptions();
+ options.PopValidation.SignedHttpRequestLifetime = TimeSpan.FromSeconds(seconds);
+
+ // Act
+ ValidateOptionsResult result = new ValidateSidecarOptions().Validate(name: null, options);
+
+ // Assert
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public void Validate_WithDefaultOptions_Succeeds()
+ {
+ // Arrange: binding with no Sidecar:PopValidation section yields the five-minute default.
+ var options = new SidecarOptions();
+
+ // Act
+ ValidateOptionsResult result = new ValidateSidecarOptions().Validate(name: null, options);
+
+ // Assert
+ Assert.True(result.Succeeded);
+ }
+}
From 3ed2d590a4618c2ab8925714cddf2d4b5e9c3f86 Mon Sep 17 00:00:00 2001
From: Tanuj Sood <113442573+soodt@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:40:28 +0100
Subject: [PATCH 9/9] Clarify app-only PoP is a token-type check, not an
authorization gate
Reword the /Validate PoP-branch comment and README so it is clear the app-only restriction is a token-type admission check, not an authorization decision. AzureAd:Scopes enforcement targets delegated 'scp' claims (Bearer only); app-only PoP tokens carry none, so authorizing the returned app identity remains the caller's responsibility. Documentation-only; no behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Endpoints/ValidateRequestEndpoints.cs | 12 ++++++++----
src/Microsoft.Identity.Web.Sidecar/README.md | 6 +++++-
2 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
index 1d982a102..c23ba87a9 100644
--- a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
+++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs
@@ -30,10 +30,14 @@ private static Results, ProblemHttpResult>
HttpContext httpContext,
[FromServices] IConfiguration configuration)
{
- // A validated inbound SHR PoP request: the PoP handler has already validated the outer signature
- // and the embedded app-only access token (delegated/user tokens are rejected there) and stashed
- // it here. Scope enforcement (AzureAd:Scopes -> VerifyUserHasAnyAcceptedScope) targets delegated
- // 'scp' claims and so does not apply to app-only PoP; the app-only guard above is the equivalent gate.
+ // The PoP handler has already validated the outer SHR signature and the embedded access token,
+ // and rejected any non-app-only (delegated/user) token, before stashing the result here.
+ //
+ // This branch intentionally returns before the AzureAd:Scopes check below. That check
+ // (VerifyUserHasAnyAcceptedScope) enforces delegated 'scp' scopes, which app-only tokens do not
+ // carry — so unlike the Bearer path below, this path applies no scope gate. The app-only
+ // restriction is a token-type admission check, not an authorization decision; authorizing the
+ // returned app identity (e.g. by roles/appid) remains the caller's responsibility.
if (httpContext.Items[PopConstants.ValidatedAccessTokenItemKey] is JsonWebToken popToken)
{
return BuildResult(logger, PopConstants.ProtocolName, popToken);
diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md
index 0dfd16df6..572bee10d 100644
--- a/src/Microsoft.Identity.Web.Sidecar/README.md
+++ b/src/Microsoft.Identity.Web.Sidecar/README.md
@@ -163,7 +163,7 @@ For all credential configuration options, see the [CredentialDescription documen
| Endpoint | Method | Auth | Description |
| ----------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ |
-| `/Validate` | GET | Required | Returns the token and its claims. Accepts a `Bearer` or app-only `PoP` credential. Enforces `AzureAd:Scopes` when configured. |
+| `/Validate` | GET | Required | Returns the token and its claims. Accepts a `Bearer` or app-only `PoP` credential. Enforces `AzureAd:Scopes` (when configured) for `Bearer` only; app-only `PoP` tokens carry no `scp`, so authorizing the returned app identity is the caller's responsibility. |
| `/AuthorizationHeader/{apiName}` | GET | Required | Returns an `Authorization` header for the named downstream API using the caller’s identity. |
| `/AuthorizationHeaderUnauthenticated/{apiName}` | GET | Optional | Uses the sidecar’s application identity to obtain a token. |
| `/DownstreamApi/{apiName}` | POST | Required | Invokes the downstream API profile with the caller’s identity, forwarding body and content-type. |
@@ -206,6 +206,10 @@ Agent identity parameters are also subject to the per-route override flag:
calling application; the sidecar does not independently observe the downstream request. The
`ts`-based freshness check (default five minutes) is not a replay cache, and server nonce is not
implemented.
+- `/Validate` does not authorize app-only `PoP` callers: `AzureAd:Scopes` gates delegated `scp`
+ claims, which app-only tokens do not carry. The sidecar only confirms the token is a valid
+ app-only PoP token; authorizing that app identity (for example by its roles / application
+ permissions) is the calling application's responsibility.
## Runtime composition