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