Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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;

/// <summary>
/// Tunable flags for inbound Signed HTTP Request (SHR) Proof-of-Possession validation, bound
/// from the <c>Sidecar:PopValidation</c> configuration subsection. Every property defaults to the
/// secure Microsoft.IdentityModel <c>SignedHttpRequestValidationParameters</c> 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.
/// <para>
/// Only members exposed as simple configuration values are surfaced here; body-hash (<c>b</c>)
/// binding is not configurable and remains off.
/// </para>
/// </summary>
public class PopValidationOptions
{
/// <summary>
/// Gets or sets a value indicating whether the <c>ts</c> (timestamp) claim is validated - the
/// replay/freshness guard. Default <c>true</c>; disabling it removes replay protection.
/// </summary>
[DefaultValue(true)]
public bool ValidateTs { get; set; } = true;
Comment thread
iNinja marked this conversation as resolved.
Resolved

/// <summary>
/// Gets or sets a value indicating whether the <c>m</c> (HTTP method) claim is validated - request
/// binding. Default <c>true</c>; disabling it lets a captured SHR be replayed against another method.
/// </summary>
[DefaultValue(true)]
public bool ValidateM { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether the <c>u</c> (host + scheme) claim is validated - request
/// binding. Default <c>true</c>; disabling it lets a captured SHR be replayed against another host.
/// </summary>
[DefaultValue(true)]
public bool ValidateU { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether the <c>p</c> (path) claim is validated - request binding.
/// Default <c>true</c>; disabling it lets a captured SHR be replayed against another path.
/// </summary>
[DefaultValue(true)]
public bool ValidateP { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether the <c>q</c> (query parameters) claim is validated -
/// request binding. Default <c>false</c>; enabling it hardens binding but the caller must sign
/// <c>q</c> or every request fails.
/// </summary>
[DefaultValue(false)]
public bool ValidateQ { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the <c>h</c> (headers) claim is validated - request
/// binding. Default <c>false</c>; enabling it hardens binding but the caller must sign <c>h</c> or
/// every request fails.
/// </summary>
[DefaultValue(false)]
public bool ValidateH { get; set; }
Comment thread
iNinja marked this conversation as resolved.
Resolved

/// <summary>
/// Gets or sets a value indicating whether headers not covered by the signature are accepted.
/// Default <c>true</c>.
/// </summary>
[DefaultValue(true)]
public bool AcceptUnsignedHeaders { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether query parameters not covered by the signature are
/// accepted. Default <c>true</c>.
/// </summary>
[DefaultValue(true)]
public bool AcceptUnsignedQueryParameters { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether the claims listed in <see cref="ClaimsToValidateWhenPresent"/>
/// are validated when present (even if their individual flag is off). Default <c>false</c>; while
/// <c>false</c> the <see cref="ClaimsToValidateWhenPresent"/> list is inert.
/// </summary>
[DefaultValue(false)]
public bool ValidatePresentClaims { get; set; }

/// <summary>
/// Gets or sets the signed HTTP request lifetime - the <c>ts</c> clock-skew tolerance. Default
/// 5 minutes.
/// </summary>
public TimeSpan SignedHttpRequestLifetime { get; set; } = TimeSpan.FromMinutes(5);

/// <summary>
/// Gets or sets the claims validated only when present, gated by <see cref="ValidatePresentClaims"/>.
/// Default <c>{ "m", "p" }</c>. Inert unless <see cref="ValidatePresentClaims"/> is <c>true</c>.
/// </summary>
public IList<string> ClaimsToValidateWhenPresent { get; set; } = new Collection<string> { "m", "p" };
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public class SidecarOptions
/// from downstream APIs. Defaults to <c>false</c>.
/// </summary>
public bool AllowOutboundRedirects { get; set; }

/// <summary>
/// Gets or sets the tunable inbound SHR PoP validation flags, bound from the
/// <c>Sidecar:PopValidation</c> subsection. When the subsection is absent every flag keeps its
/// secure default, so PoP validation runs with method/URI/path/timestamp binding on.
/// </summary>
public PopValidationOptions PopValidation { get; set; } = new();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand All @@ -29,6 +30,15 @@ private static Results<Ok<ValidateAuthorizationHeaderResult>, 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.
if (httpContext.Items[PopConstants.ValidatedAccessTokenItemKey] is JsonWebToken popToken)
Comment thread
iNinja marked this conversation as resolved.
Resolved
{
return BuildResult(logger, PopConstants.ProtocolName, popToken);
}

string scopeRequiredByApi = configuration["AzureAd:Scopes"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(scopeRequiredByApi))
{
Expand All @@ -42,6 +52,14 @@ private static Results<Ok<ValidateAuthorizationHeaderResult>, ProblemHttpResult>
return TypedResults.Problem("No token found", statusCode: StatusCodes.Status400BadRequest);
}

return BuildResult(logger, "Bearer", token);
}

private static Results<Ok<ValidateAuthorizationHeaderResult>, ProblemHttpResult> BuildResult(
ILogger logger,
string protocol,
JsonWebToken token)
{
var decodedBody = Base64Url.DecodeFromChars(token.EncodedPayload);

JsonNode? jsonDoc;
Expand All @@ -62,7 +80,7 @@ private static Results<Ok<ValidateAuthorizationHeaderResult>, ProblemHttpResult>
}

var result = new ValidateAuthorizationHeaderResult(
Protocol: "Bearer",
Protocol: protocol,
Token: token.EncodedToken,
Claims: jsonDoc
);
Expand Down
10 changes: 10 additions & 0 deletions src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 an identity-model 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(MicrosoftAspNetCoreAuthenticationJwtBearerVersion)" NoWarn="$(NoWarn);NU1605" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.SignedHttpRequest" Version="$(MicrosoftIdentityModelVersion)" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="$(MicrosoftAspNetCoreOpenApiVersion)" />
<PackageReference Include="Microsoft.OpenApi" Version="$(MicrosoftOpenApiVersion)" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="$(MicrosoftExtensionsApiDescriptionServerVersion)">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@Microsoft.Identity.Web.Sidecar_HostAddress = http://localhost:5178

@AccessToken =
@PopToken =
@ApiName =

###
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
namespace Microsoft.Identity.Web.Sidecar.Models;

/// <summary>
/// The result of validation an authorization header.
/// The result of validating an authorization header.
/// </summary>
/// <param name="Protocol">The protocol.</param>
/// <param name="Protocol">The authentication protocol the credential was validated with: <c>Bearer</c> or <c>PoP</c>.</param>
/// <param name="Token">The token validated.</param>
/// <param name="Claims">The claims parsed from the token.</param>
public record ValidateAuthorizationHeaderResult(string Protocol, string Token, JsonNode Claims);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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;

/// <summary>
/// Wires inbound SHR PoP into the sidecar's authentication pipeline without changing the Bearer scheme
/// or the global default authentication scheme.
/// </summary>
/// <remarks>
/// Registers a second authentication handler under the "PoP" scheme and a named authorization policy
/// (<see cref="PopConstants.ValidatePolicyName"/>) that accepts both "Bearer" and "PoP". Only the
/// <c>/Validate</c> 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.
/// </remarks>
internal static class PopAuthenticationExtensions
{
public static AuthenticationBuilder AddInboundShrPop(this AuthenticationBuilder builder)
{
builder.Services.AddSingleton<ShrPopValidationService>();

builder.AddScheme<PopAuthenticationSchemeOptions, PopAuthenticationHandler>(
PopConstants.SchemeName,
static _ => { });

// Accept both Bearer and PoP on /Validate. The global default scheme stays "Bearer".
builder.Services
.AddAuthorizationBuilder()
.AddPolicy(PopConstants.ValidatePolicyName, policy =>
{
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, PopConstants.SchemeName);
policy.RequireAuthenticatedUser();
});

return builder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 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;

/// <summary>Options for the PoP authentication scheme.</summary>
internal sealed class PopAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
}

/// <summary>
/// ASP.NET Core authentication handler for inbound SHR PoP. It recognises the "PoP" Authorization
/// scheme, runs <see cref="ShrPopValidationService"/>, and on success builds a
/// <see cref="ClaimsPrincipal"/> from the validated inner access token. The Bearer (JwtBearer) handler
/// is untouched; the <c>/Validate</c> endpoint opts into a policy that accepts both schemes.
/// </summary>
internal sealed class PopAuthenticationHandler : AuthenticationHandler<PopAuthenticationSchemeOptions>
{
private readonly ShrPopValidationService _validationService;

public PopAuthenticationHandler(
IOptionsMonitor<PopAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ShrPopValidationService validationService)
: base(options, logger, encoder)
{
_validationService = validationService;
}

protected override async Task<AuthenticateResult> 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.";
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);
}
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;
}
}
35 changes: 35 additions & 0 deletions src/Microsoft.Identity.Web.Sidecar/Pop/PopConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.IdentityModel.Protocols.SignedHttpRequest;

namespace Microsoft.Identity.Web.Sidecar.Pop;

/// <summary>
/// Constants for the inbound Signed HTTP Request (SHR) Proof-of-Possession path: the "PoP"
/// Authorization scheme token and the request-line contract header names.
/// </summary>
internal static class PopConstants
{
/// <summary>The Authorization scheme token for SHR PoP ("PoP"). Sourced from Microsoft.IdentityModel so it never drifts.</summary>
public const string SchemeName = SignedHttpRequestConstants.AuthorizationHeaderSchemeName;

/// <summary>Protocol label returned to the caller for a validated PoP request.</summary>
public const string ProtocolName = "PoP";

/// <summary>
/// Name of the authorization policy applied to the <c>/Validate</c> 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").
/// </summary>
public const string ValidatePolicyName = "SidecarValidateBearerOrPoP";

/// <summary>Header carrying the original (signed) request absolute URI.</summary>
public const string OriginalUriHeaderName = "original-uri";

/// <summary>Header carrying the original (signed) request HTTP method.</summary>
public const string OriginalMethodHeaderName = "original-method";

/// <summary>HttpContext.Items key holding the validated inner access token (a JsonWebToken).</summary>
public const string ValidatedAccessTokenItemKey = "Sidecar.Pop.ValidatedAccessToken";
}
Loading
Loading