diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index a3fa6816b..3551e513c 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -48,7 +48,7 @@ jobs: run: dotnet test --no-restore --no-build Microsoft.Identity.Web.sln -f net8.0 -v m -p:FROM_GITHUB_ACTION=true --configuration Release --collect "Xplat Code Coverage" --filter "(FullyQualifiedName!~Microsoft.Identity.Web.Test.Integration)&(FullyQualifiedName!~WebAppUiTests)&(FullyQualifiedName!~IntegrationTests)&(FullyQualifiedName!~TokenAcquirerTests)&(FullyQualifiedName!~AgentApplicationsTests)" - name: Test with .NET 9.0.x - run: dotnet test --no-restore --no-build Microsoft.Identity.Web.sln -f net9.0 -v m -p:FROM_GITHUB_ACTION=true --configuration Release --collect "Xplat Code Coverage" --filter "(FullyQualifiedName!~Microsoft.Identity.Web.Test.Integration)&(FullyQualifiedName!~WebAppUiTests)&(FullyQualifiedName!~IntegrationTests)&(FullyQualifiedName!~TokenAcquirerTests)&(FullyQualifiedName!~AgentApplicationsTests)" + run: dotnet test --no-restore --no-build Microsoft.Identity.Web.sln -f net9.0 -v m -p:FROM_GITHUB_ACTION=true --configuration Release --collect "Xplat Code Coverage" --filter "(FullyQualifiedName!~Microsoft.Identity.Web.Test.Integration)&(FullyQualifiedName!~WebAppUiTests)&(FullyQualifiedName!~IntegrationTests)&(FullyQualifiedName!~TokenAcquirerTests)&(FullyQualifiedName!~AgentApplicationsTests)&(FullyQualifiedName!~SidecarEndpointsE2ETests)" diff --git a/.gitignore b/.gitignore index 1024d6b3a..904b8061a 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,12 @@ MigrationBackup/ /.SharedData /out/ objd/ +/src/Microsoft.Identity.Web.Sidecar/http-client.env.json + +# Generated data protection keys + +key*.xml + +# bin files + +*.bin diff --git a/Microsoft.Identity.Web.sln b/Microsoft.Identity.Web.sln index d1a224f86..934728fad 100644 --- a/Microsoft.Identity.Web.sln +++ b/Microsoft.Identity.Web.sln @@ -170,6 +170,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Web.Agen EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "daemon-app-msi", "tests\DevApps\daemon-app\daemon-app-msi\daemon-app-msi.csproj", "{A8181404-23E0-D38B-454C-D16ECDB18B9F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Web.Sidecar", "src\Microsoft.Identity.Web.Sidecar\Microsoft.Identity.Web.Sidecar.csproj", "{55C81F88-0FFA-491C-A1D7-0ACA7212B59C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidecar.Tests", "tests\E2E Tests\Sidecar.Tests\Sidecar.Tests.csproj", "{946E6BED-2A06-4FF4-3E39-22ACEB44A984}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -405,6 +409,14 @@ Global {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Release|Any CPU.Build.0 = Release|Any CPU + {55C81F88-0FFA-491C-A1D7-0ACA7212B59C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55C81F88-0FFA-491C-A1D7-0ACA7212B59C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55C81F88-0FFA-491C-A1D7-0ACA7212B59C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55C81F88-0FFA-491C-A1D7-0ACA7212B59C}.Release|Any CPU.Build.0 = Release|Any CPU + {946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Debug|Any CPU.Build.0 = Debug|Any CPU + {946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Release|Any CPU.ActiveCfg = Release|Any CPU + {946E6BED-2A06-4FF4-3E39-22ACEB44A984}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -482,6 +494,8 @@ Global {DD56CDF7-E6B3-4304-B8DF-3AC610C35623} = {45B20A78-91F8-4DD2-B9AD-F12D3A93536C} {C14780ED-5756-2A09-C6A7-5DDA433D1E86} = {1DDE1AAC-5AE6-4725-94B6-A26C58D3423F} {A8181404-23E0-D38B-454C-D16ECDB18B9F} = {E37CDBC1-18F6-4C06-A3EE-532C9106721F} + {55C81F88-0FFA-491C-A1D7-0ACA7212B59C} = {1DDE1AAC-5AE6-4725-94B6-A26C58D3423F} + {946E6BED-2A06-4FF4-3E39-22ACEB44A984} = {45B20A78-91F8-4DD2-B9AD-F12D3A93536C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {104367F1-CE75-4F40-B32F-F14853973187} diff --git a/src/Microsoft.Identity.Web.AgentIdentities/Microsoft.Identity.Web.AgentIdentities.csproj b/src/Microsoft.Identity.Web.AgentIdentities/Microsoft.Identity.Web.AgentIdentities.csproj index d8231b45c..6a94cc28e 100644 --- a/src/Microsoft.Identity.Web.AgentIdentities/Microsoft.Identity.Web.AgentIdentities.csproj +++ b/src/Microsoft.Identity.Web.AgentIdentities/Microsoft.Identity.Web.AgentIdentities.csproj @@ -3,7 +3,7 @@ Microsoft Identity Web Agentic Identity support Microsoft Identity Web for Agent Identities - Helper methods for Agent applications to act as the agent identities. + Helper methods for Agent identity blueprint to act as the agent identities. README.md diff --git a/src/Microsoft.Identity.Web.OidcFIC/OidcFicSignedAssertionProviderExtensions.cs b/src/Microsoft.Identity.Web.OidcFIC/OidcFicSignedAssertionProviderExtensions.cs index dced66656..3f693a93f 100644 --- a/src/Microsoft.Identity.Web.OidcFIC/OidcFicSignedAssertionProviderExtensions.cs +++ b/src/Microsoft.Identity.Web.OidcFIC/OidcFicSignedAssertionProviderExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; using Microsoft.Identity.Web.OidcFic; namespace Microsoft.Extensions.DependencyInjection @@ -20,6 +21,7 @@ public static class OidcFicSignedAssertionProviderExtensions /// the service collection for chaining. public static IServiceCollection AddOidcFic(this IServiceCollection services) { + services.AddTokenAcquisition(true); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/src/Microsoft.Identity.Web.Sidecar/AgentOverrides.cs b/src/Microsoft.Identity.Web.Sidecar/AgentOverrides.cs new file mode 100644 index 000000000..c450a8551 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/AgentOverrides.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web.Sidecar; + +public class AgentOverrides +{ + /// + /// Applies agent identity overrides to . + /// Precedence: + /// 1. If an agent identity and a username (UPN) are provided, use agent user identity (username wins over userId). + /// 2. Else if an agent identity and a userId (OID) are provided, use agent user identity with the OID. + /// 3. Else if only an agent identity is provided, use agent identity. + /// To override the tenant, set options.AcquireTokenOptions.Tenant separately. + /// + /// Downstream API options to mutate. + /// Agent identity (client/application ID) to act as. + /// Agent user identity UPN. + /// Agent user identity object id (GUID string). + public static void SetOverrides( + DownstreamApiOptions options, + string? agentIdentity, + string? agentUsername, + [StringSyntax(StringSyntaxAttribute.GuidFormat)] + string? agentUserId) + { + if (options is null || string.IsNullOrWhiteSpace(agentIdentity)) + { + return; + } + + // Username (UPN) takes precedence if both UPN and OID are supplied. + if (!string.IsNullOrWhiteSpace(agentUsername)) + { + options.WithAgentUserIdentity(agentIdentity, agentUsername); + } + else if (!string.IsNullOrWhiteSpace(agentUserId) && + Guid.TryParse(agentUserId, out Guid userGuid)) + { + options.WithAgentUserIdentity(agentIdentity, userGuid); + } + else + { + // Fallback to plain agent identity. + options.WithAgentIdentity(agentIdentity); + } + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/AppJsonSerializerContext.cs b/src/Microsoft.Identity.Web.Sidecar/AppJsonSerializerContext.cs new file mode 100644 index 000000000..184f35df4 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/AppJsonSerializerContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Microsoft.Identity.Web.Sidecar.Models; + +namespace Microsoft.Identity.Web.Sidecar; + +[JsonSerializable(typeof(AuthorizationHeaderResult))] +[JsonSerializable(typeof(DownstreamApiResult))] +[JsonSerializable(typeof(ValidateAuthorizationHeaderResult))] +internal partial class AppJsonSerializerContext : JsonSerializerContext +{ + +} diff --git a/src/Microsoft.Identity.Web.Sidecar/CacheControl.cs b/src/Microsoft.Identity.Web.Sidecar/CacheControl.cs new file mode 100644 index 000000000..81330bdeb --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/CacheControl.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Net.Http.Headers; + +namespace Microsoft.Identity.Web.Sidecar; + +public static class CacheControl +{ + private readonly static string s_cacheControlHeader = $"{CacheControlHeaderValue.NoCacheString}, {CacheControlHeaderValue.NoStoreString}, {CacheControlHeaderValue.MustRevalidateString}"; + + + public static void SetNoCachingMiddleware(this WebApplication app) + { + app.Use(async (context, next) => + { + context.Response.OnStarting(() => + { + if (context.Response.StatusCode is >= 200 and < 300) + { + CacheControl.SetNoCaching(context.Response); + } + return Task.CompletedTask; + }); + + await next(); + }); + } + + private static void SetNoCaching(HttpResponse response) + { + // using Microsoft.Net.Http.Headers + response.Headers[HeaderNames.CacheControl] = s_cacheControlHeader; + response.Headers[HeaderNames.Expires] = "0"; + response.Headers[HeaderNames.Pragma] = CacheControlHeaderValue.NoCacheString; + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Directory.Build.props b/src/Microsoft.Identity.Web.Sidecar/Directory.Build.props new file mode 100644 index 000000000..877b65084 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Directory.Build.props @@ -0,0 +1,14 @@ + + + + net9.0 + + + 9.0.9 + $(AspDependencyVersion) + $(AspDependencyVersion) + $(AspDependencyVersion) + $(AspDependencyVersion) + 1.22.1 + + diff --git a/src/Microsoft.Identity.Web.Sidecar/DockerFile.NanoServer b/src/Microsoft.Identity.Web.Sidecar/DockerFile.NanoServer new file mode 100644 index 000000000..b2f1c26d0 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/DockerFile.NanoServer @@ -0,0 +1,9 @@ +# This docker file is used for building the container image from a published set of files. +# This is used when for workflows where the files need to be signed in CI/CD. + +FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS base +WORKDIR /app + +COPY app/publish . +USER app +ENTRYPOINT ["dotnet", "Microsoft.Identity.Web.Sidecar.dll"] diff --git a/src/Microsoft.Identity.Web.Sidecar/Dockerfile b/src/Microsoft.Identity.Web.Sidecar/Dockerfile new file mode 100644 index 000000000..508d5dbbf --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Dockerfile @@ -0,0 +1,29 @@ +# This file is used for local builds and can be used with the Visual Studio tools to publish a container. + +# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +# This stage is used when running from VS in fast mode (Default for Debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app + +# This stage is used to build the service project +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Microsoft.Identity.Web.Sidecar.csproj", "."] +RUN dotnet restore "./Microsoft.Identity.Web.Sidecar.csproj" +COPY . . +WORKDIR "/src/." +RUN dotnet build "./Microsoft.Identity.Web.Sidecar.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# This stage is used to publish the service project to be copied to the final stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Microsoft.Identity.Web.Sidecar.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Microsoft.Identity.Web.Sidecar.dll"] diff --git a/src/Microsoft.Identity.Web.Sidecar/Dockerfile.AzureLinux b/src/Microsoft.Identity.Web.Sidecar/Dockerfile.AzureLinux new file mode 100644 index 000000000..bf959368c --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Dockerfile.AzureLinux @@ -0,0 +1,11 @@ +# This docker file is used for building the container image from a published set of files. +# This is used when for workflows where the files need to be signed in CI/CD. + +FROM mcr.microsoft.com/dotnet/aspnet:9.0-azurelinux3.0-distroless AS base +WORKDIR /app + +COPY app/publish . + +USER app + +ENTRYPOINT ["dotnet", "Microsoft.Identity.Web.Sidecar.dll"] diff --git a/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs b/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs new file mode 100644 index 000000000..a9ccddd85 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web.Sidecar; + +public static class DownstreamApiOptionsMerger +{ + public static DownstreamApiOptions MergeOptions(DownstreamApiOptions left, DownstreamApiOptions right) + { + var res = left.Clone(); + + if (right is null) + { + return res; + } + + if (right.Scopes is not null && right.Scopes.Any()) + { + res.Scopes = right.Scopes; + } + + if (!string.IsNullOrEmpty(right.AcquireTokenOptions.Tenant)) + { + res.AcquireTokenOptions.Tenant = right.AcquireTokenOptions.Tenant; + } + + if (!string.IsNullOrEmpty(right.AcquireTokenOptions.Claims)) + { + res.AcquireTokenOptions.Claims = right.AcquireTokenOptions.Claims; + } + + if (!string.IsNullOrEmpty(right.AcquireTokenOptions.AuthenticationOptionsName)) + { + res.AcquireTokenOptions.AuthenticationOptionsName = right.AcquireTokenOptions.AuthenticationOptionsName; + } + + if (!string.IsNullOrEmpty(right.AcquireTokenOptions.FmiPath)) + { + res.AcquireTokenOptions.FmiPath = right.AcquireTokenOptions.FmiPath; + } + + if (!string.IsNullOrEmpty(right.RelativePath)) + { + res.RelativePath = right.RelativePath; + } + + res.AcquireTokenOptions.ForceRefresh = right.AcquireTokenOptions.ForceRefresh; + + if (right.AcquireTokenOptions.ExtraParameters is not null) + { + if (res.AcquireTokenOptions.ExtraParameters is null) + { + res.AcquireTokenOptions.ExtraParameters = new Dictionary(); + } + foreach (var extraParameter in right.AcquireTokenOptions.ExtraParameters) + { + if (!res.AcquireTokenOptions.ExtraParameters.ContainsKey(extraParameter.Key)) + { + res.AcquireTokenOptions.ExtraParameters.Add(extraParameter.Key, extraParameter.Value); + } + } + } + + return res; + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs new file mode 100644 index 000000000..ab3ad1229 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/AuthorizationHeaderEndpoint.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Sidecar.Logging; +using Microsoft.Identity.Web.Sidecar.Models; + +namespace Microsoft.Identity.Web.Sidecar.Endpoints; + +public static class AuthorizationHeaderEndpoint +{ + public static void AddAuthorizationHeaderRequestEndpoints(this WebApplication app) + { + app.MapGet("/AuthorizationHeader/{apiName}", AuthorizationHeaderAsync). + WithName("AuthorizationHeader"). + RequireAuthorization(). + ProducesProblem(StatusCodes.Status400BadRequest). + ProducesProblem(StatusCodes.Status401Unauthorized). + WithSummary("Get an authorization header for a configured downstream API."). + WithDescription( + "This endpoint will use the identity of the authenticated request to acquire an authorization header." + + "Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. " + + "Examples:\n" + + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n" + + " ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" + + "Repeat parameters like 'optionsOverride.Scopes' to add multiple scopes."); + + app.MapGet("/AuthorizationHeaderUnauthenticated/{apiName}", AuthorizationHeaderAsync). + WithName("AuthorizationHeaderUnauthenticated"). + AllowAnonymous(). + ProducesProblem(StatusCodes.Status400BadRequest). + ProducesProblem(StatusCodes.Status401Unauthorized). + WithSummary("Get an authorization header for a configured downstream API using this configured client credentials."). + WithDescription( + "This endpoint will use the configured client credentials to acquire an authorization header." + + "Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. " + + "Examples:\n" + + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n" + + " ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" + + "Repeat parameters like 'optionsOverride.Scopes' to add multiple scopes."); + } + + private static async Task, ProblemHttpResult>> AuthorizationHeaderAsync( + HttpContext httpContext, + [Description("The downstream API to acquire an authorization header for.")] + [FromRoute] + string apiName, + [AsParameters] AuthorizationHeaderRequest requestParameters, + BindableDownstreamApiOptions optionsOverride, + [FromServices] IAuthorizationHeaderProvider headerProvider, + [FromServices] IOptionsMonitor optionsMonitor, + [FromServices] ILogger logger, + CancellationToken cancellationToken) + { + DownstreamApiOptions? options = optionsMonitor.Get(apiName); + + if (options is null) + { + return TypedResults.Problem( + detail: $"Not able to resolve '{apiName}'.", + statusCode: StatusCodes.Status400BadRequest); + } + + if (optionsOverride.HasAny) + { + options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride); + } + + if (options.Scopes is null) + { + return TypedResults.Problem( + detail: $"No scopes found for the API '{apiName}' or in optionsOverride. 'scopes' needs to be either a single value or a list of values.", + statusCode: StatusCodes.Status400BadRequest); + } + + AgentOverrides.SetOverrides(options, requestParameters.AgentIdentity, requestParameters.AgentUsername, requestParameters.AgentUserId); + + string authorizationHeader; + + try + { + authorizationHeader = await headerProvider.CreateAuthorizationHeaderAsync( + options.Scopes, + options, + httpContext.User, + cancellationToken); + } + catch (MicrosoftIdentityWebChallengeUserException ex) + { + logger.AuthorizationHeaderAsyncError(ex); + return TypedResults.Problem( + detail: ex.InnerException?.Message ?? ex.Message, + statusCode: StatusCodes.Status401Unauthorized); + } + catch(MsalServiceException ex) + { + logger.AuthorizationHeaderAsyncError(ex); + return TypedResults.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status401Unauthorized); + } + catch (Exception ex) + { + logger.AuthorizationHeaderAsyncError(ex); + return TypedResults.Problem( + detail: "An unexpected error occurred.", + statusCode: StatusCodes.Status500InternalServerError); + } + + return TypedResults.Ok(new AuthorizationHeaderResult(authorizationHeader)); + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs new file mode 100644 index 000000000..c869b01fd --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/DownstreamApiEndpoint.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Sidecar.Logging; +using Microsoft.Identity.Web.Sidecar.Models; + +namespace Microsoft.Identity.Web.Sidecar.Endpoints; + +public static class DownstreamApiEndpoint +{ + public static void AddDownstreamApiRequestEndpoints(this WebApplication app) + { + app.MapPost("/DownstreamApi/{apiName}", DownstreamApiAsync). + WithName("DownstreamApi"). + RequireAuthorization(). + ProducesProblem(StatusCodes.Status400BadRequest). + ProducesProblem(StatusCodes.Status401Unauthorized). + WithSummary("Invoke a configured downstream API through the sidecar using the authenticated identity."). + WithDescription( + "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " + + "Examples:\n" + + " ?optionsOverride.Scopes=User.Read\n" + + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + + " ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" + + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default"); + + app.MapPost("/DownstreamApiUnauthenticated/{apiName}", DownstreamApiAsync). + WithName("DownstreamApiUnauthenticated"). + AllowAnonymous(). + ProducesProblem(StatusCodes.Status400BadRequest). + ProducesProblem(StatusCodes.Status401Unauthorized). + WithSummary("Invoke a configured downstream API through the sidecar using the configured client credentials."). + WithDescription( + "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. " + + "Examples:\n" + + " ?optionsOverride.Scopes=User.Read\n" + + " ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n" + + " ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n" + + " ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default"); + } + + private static async Task, ProblemHttpResult>> DownstreamApiAsync( + HttpContext httpContext, + [Description("The downstream API to call")] + [FromRoute] + string apiName, + [AsParameters] DownstreamApiRequest requestParameters, + BindableDownstreamApiOptions optionsOverride, + [FromServices] IDownstreamApi downstreamApi, + [FromServices] IOptionsMonitor optionsMonitor, + [FromServices] ILogger logger, + CancellationToken cancellationToken) + { + DownstreamApiOptions? options = optionsMonitor.Get(apiName); + + if (options is null) + { + return TypedResults.Problem( + detail: $"Not able to resolve '{apiName}'.", + statusCode: StatusCodes.Status400BadRequest); + } + + if (optionsOverride.HasAny) + { + options = DownstreamApiOptionsMerger.MergeOptions(options, optionsOverride); + } + + if (options.Scopes is null || !options.Scopes.Any()) + { + return TypedResults.Problem( + detail: $"No scopes found for the API '{apiName}' or in optionsOverride. 'scopes' needs to be either a single value or a list of values.", + statusCode: StatusCodes.Status400BadRequest); + } + + AgentOverrides.SetOverrides(options, requestParameters.AgentIdentity, requestParameters.AgentUsername, requestParameters.AgentUserId); + + HttpContent? content = null; + + if (!string.IsNullOrWhiteSpace(httpContext.Request.ContentType) && + MediaTypeHeaderValue.TryParse(httpContext.Request.ContentType, out var mediaType)) + { + using var reader = new StreamReader(httpContext.Request.Body, Encoding.UTF8); + string body = await reader.ReadToEndAsync(cancellationToken); + content = new StringContent(body, Encoding.UTF8, mediaType); + } + + HttpResponseMessage downstreamResult; + + try + { + downstreamResult = await downstreamApi.CallApiAsync( + options, + httpContext.User, + content, + cancellationToken); + } + catch (MicrosoftIdentityWebChallengeUserException ex) + { + logger.AuthorizationHeaderAsyncError(ex); + return TypedResults.Problem( + detail: ex.InnerException?.Message ?? ex.Message, + statusCode: StatusCodes.Status401Unauthorized); + } + catch (MsalServiceException ex) + { + logger.AuthorizationHeaderAsyncError(ex); + return TypedResults.Problem( + detail: ex.Message, + statusCode: StatusCodes.Status401Unauthorized); + } + catch (Exception ex) + { + logger.AuthorizationHeaderAsyncError(ex); + return TypedResults.Problem( + detail: "An unexpected error occurred.", + statusCode: StatusCodes.Status500InternalServerError); + } + + string? responseContent = null; + + if (downstreamResult.Content.Headers.ContentLength > 0) + { + responseContent = await downstreamResult.Content.ReadAsStringAsync(cancellationToken); + } + + var result = new DownstreamApiResult( + (int)downstreamResult.StatusCode, + new Dictionary>(downstreamResult.Content.Headers), + responseContent); + + return TypedResults.Ok(result); + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs new file mode 100644 index 000000000..b182f48a3 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Endpoints/ValidateRequestEndpoints.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Buffers.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Identity.Web.Resource; +using Microsoft.Identity.Web.Sidecar.Logging; +using Microsoft.Identity.Web.Sidecar.Models; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Microsoft.Identity.Web.Sidecar.Endpoints; + +public static class ValidateRequestEndpoints +{ + public static void AddValidateRequestEndpoints(this WebApplication app) + { + app.MapGet("/Validate", ValidateEndpoint). + WithName("ValidateAuthorizationHeader"). + RequireAuthorization(). + ProducesProblem(StatusCodes.Status400BadRequest). + ProducesProblem(StatusCodes.Status401Unauthorized); + } + + private static Results, ProblemHttpResult> ValidateEndpoint( + [FromServices] ILogger logger, + HttpContext httpContext, + [FromServices] IConfiguration configuration) + { + string scopeRequiredByApi = configuration["AzureAd:Scopes"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(scopeRequiredByApi)) + { + httpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi); + } + + var token = httpContext.GetTokenUsedToCallWebAPI() as JsonWebToken; + + if (token is null) + { + return TypedResults.Problem("No token found", statusCode: StatusCodes.Status400BadRequest); + } + + var decodedBody = Base64Url.DecodeFromChars(token.EncodedPayload); + + JsonNode? jsonDoc; + try + { + jsonDoc = JsonNode.Parse(decodedBody); + } + catch (JsonException ex) + { + logger.UnableToParseToken(ex); + return TypedResults.Problem("Invalid JSON in token payload", statusCode: StatusCodes.Status400BadRequest); + } + + if (jsonDoc is null) + { + logger.UnableToParseToken(null); + return TypedResults.Problem("Failed to decode token claims", statusCode: StatusCodes.Status400BadRequest); + } + + var result = new ValidateAuthorizationHeaderResult( + Protocol: "Bearer", + Token: token.EncodedToken, + Claims: jsonDoc + ); + + return TypedResults.Ok(result); + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs new file mode 100644 index 000000000..18af28346 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Logging/Logging.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Web.Sidecar.Logging; + +public static partial class LoggerMessageExtensions +{ + [LoggerMessage( + EventId = 1, + Level = LogLevel.Error, + Message = "An error occurred while creating an authorization header.", + EventName = "AuthorizationHeaderAsyncError_CreateAuthorizationHeaderAsync")] + public static partial void AuthorizationHeaderAsyncError(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Error, + Message = "An error occurred while parsing the token.", + EventName = "ValidateRequest_UnableToParseToken")] + public static partial void UnableToParseToken(this ILogger logger, Exception? exception); +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj new file mode 100644 index 000000000..1b37a4ee9 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.csproj @@ -0,0 +1,54 @@ + + + + enable + enable + aspnet-Microsoft.Identity.Web.Sidecar-79d2a631-277f-4ef1-9253-4477001378e8 + OpenAPI + False + false + false + true + true + + $(NoWarn); + + RS0016; + + RS0036; + + RS0037; + + RS0051 + + + + + + true + + + false + + false + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http new file mode 100644 index 000000000..052b1a210 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Microsoft.Identity.Web.Sidecar.http @@ -0,0 +1,42 @@ +@Microsoft.Identity.Web.Sidecar_HostAddress = http://localhost:5178 + +@AccessToken = + +### + +GET {{Microsoft.Identity.Web.Sidecar_HostAddress}}/openapi/v1.json +Accept: application/json + +### + +GET {{Microsoft.Identity.Web.Sidecar_HostAddress}}/Validate +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{Microsoft.Identity.Web.Sidecar_HostAddress}}/AuthorizationHeader/me +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{Microsoft.Identity.Web.Sidecar_HostAddress}}/AuthorizationHeaderUnauthenticated/me +Accept: application/json + +### + +GET {{Microsoft.Identity.Web.Sidecar_HostAddress}}/AuthorizationHeader/me?optionsOverride.Scopes=User.Read&optionsOverride.AcquireTokenOptions.Tenant=f645ad92-e38d-4d1a-b510-d1b09a74a8ca +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{Microsoft.Identity.Web.Sidecar_HostAddress}}/DownstreamApi/me +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{Microsoft.Identity.Web.Sidecar_HostAddress}}/DownstreamApiUnauthenticated/me +Accept: application/json diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/AuthorizationHeaderRequest.cs b/src/Microsoft.Identity.Web.Sidecar/Models/AuthorizationHeaderRequest.cs new file mode 100644 index 000000000..4adb3c5cd --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Models/AuthorizationHeaderRequest.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.Identity.Web.Sidecar.Models; + +/// +/// Represents the inputs to +/// +/// +public readonly struct AuthorizationHeaderRequest +{ + [FromQuery] + [Description("The identity of the agent.")] + public string? AgentIdentity { get; init; } + + [FromQuery] + [Description("The username (UPN) of the user agent identity.")] + public string? AgentUsername { get; init; } + + [FromQuery] + [Description("The Object ID of the agent (OID).")] + [StringSyntax(StringSyntaxAttribute.GuidFormat)] + public string? AgentUserId { get; init; } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/AuthorizationHeaderResult.cs b/src/Microsoft.Identity.Web.Sidecar/Models/AuthorizationHeaderResult.cs new file mode 100644 index 000000000..dc21b970c --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Models/AuthorizationHeaderResult.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Web.Sidecar.Models; + +/// +/// The result of requesting an authorization header. +/// +/// The authorization header. +public record AuthorizationHeaderResult(string AuthorizationHeader); diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs new file mode 100644 index 000000000..3f25a3bb1 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Reflection; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web.Sidecar.Models; + +/// +/// Downstream options that can be bound from query (dotted keys). +/// Supports simple key-value format: +/// OptionsOverride=Scopes=User.Read,Mail.Read&OptionsOverride.AcquireTokenOptions=Tenant=foo.onmicrosoft.com&OptionsOverride=RelativePath=me +/// +public class BindableDownstreamApiOptions : DownstreamApiOptions, IEndpointParameterMetadataProvider +{ + /// + /// The object needs to be non-nullable for the OpenAPI spec generation. + /// This provides a way to know if any override was actually provided. + /// + public bool HasAny { get; private set; } + + public BindableDownstreamApiOptions() + { + } + + public static ValueTask BindAsync(HttpContext ctx, ParameterInfo parameter) + { + var paramName = parameter.Name ?? "optionsOverride"; + bool hasAny = ctx.Request.Query.Keys.Any(k => + k.StartsWith(paramName + ".", StringComparison.OrdinalIgnoreCase)); + + var result = new BindableDownstreamApiOptions(); + + if (!hasAny) + { + return ValueTask.FromResult(result); + } + + result.HasAny = true; + + var query = ctx.Request.Query; + + foreach (var key in query.Keys) + { + if (!key.StartsWith(paramName + ".", StringComparison.OrdinalIgnoreCase)) + continue; + + var path = key.Substring(paramName.Length + 1); // remove "optionsOverride." + var values = query[key]; + + if (path.Equals("Scopes", StringComparison.OrdinalIgnoreCase)) + { + List scopes = result.Scopes is null ? [] : [.. result.Scopes]; + foreach (var v in values) + { + if (!string.IsNullOrWhiteSpace(v)) + scopes.Add(v); + } + result.Scopes = scopes; + } + else if (path.Equals("RequestAppToken", StringComparison.OrdinalIgnoreCase)) + { + if (bool.TryParse(values.LastOrDefault(), out var b)) + result.RequestAppToken = b; + } + else if (path.StartsWith("AcquireTokenOptions.", StringComparison.OrdinalIgnoreCase)) + { + var sub = path.Substring("AcquireTokenOptions.".Length); + var last = values.LastOrDefault(); + if (string.IsNullOrEmpty(last)) continue; + + switch (sub.ToLowerInvariant()) + { + case "tenant": + result.AcquireTokenOptions.Tenant = last; + break; + case "forcerefresh": + if (bool.TryParse(last, out var fr)) + result.AcquireTokenOptions.ForceRefresh = fr; + break; + case "claims": + result.AcquireTokenOptions.Claims = last; + break; + case "correlationid" when Guid.TryParse(last, out var corrId): + result.AcquireTokenOptions.CorrelationId = corrId; + break; + case "fmipath": + result.AcquireTokenOptions.FmiPath = last; + break; + case "longrunningwebapisessionkey": + result.AcquireTokenOptions.LongRunningWebApiSessionKey = last; + break; + case "poppublickey": + result.AcquireTokenOptions.PopPublicKey = last; + break; + case "managedidentity.userassignedclientid": + result.AcquireTokenOptions.ManagedIdentity ??= new(); + result.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId = last; + break; + } + } + else if (path.Equals("BaseUrl", StringComparison.OrdinalIgnoreCase)) + { + result.BaseUrl = values.LastOrDefault(); + } + else if (path.Equals("RelativePath", StringComparison.OrdinalIgnoreCase)) + { + result.RelativePath = values.LastOrDefault() ?? string.Empty; + } + else if (path.Equals("HttpMethod", StringComparison.OrdinalIgnoreCase)) + { + result.HttpMethod = values.LastOrDefault() ?? string.Empty; + } + else if (path.Equals("ContentType", StringComparison.OrdinalIgnoreCase)) + { + result.ContentType = values.LastOrDefault() ?? string.Empty; + } + else if (path.Equals("AcceptHeader", StringComparison.OrdinalIgnoreCase)) + { + result.AcceptHeader = values.LastOrDefault() ?? string.Empty; + } + } + + return ValueTask.FromResult(result); + } + + /// + public static void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) + { + builder.Metadata.Add(new FromQueryAttribute()); + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/DownstreamApiRequest.cs b/src/Microsoft.Identity.Web.Sidecar/Models/DownstreamApiRequest.cs new file mode 100644 index 000000000..ca02e3cd4 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Models/DownstreamApiRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.Identity.Web.Sidecar.Models; + +/// +/// Represents the inputs to the downstream API endpoint. +/// +public readonly struct DownstreamApiRequest +{ + [FromQuery] + [Description("The identity of the agent.")] + public string? AgentIdentity { get; init; } + + [FromQuery] + [Description("The username (UPN) of the agent.")] + public string? AgentUsername { get; init; } + + [FromQuery] + [Description("The Object ID of the user agent identity (OID).")] + [StringSyntax(StringSyntaxAttribute.GuidFormat)] + public string? AgentUserId { get; init; } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/DownstreamApiResult.cs b/src/Microsoft.Identity.Web.Sidecar/Models/DownstreamApiResult.cs new file mode 100644 index 000000000..3915ca8e9 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Models/DownstreamApiResult.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Web.Sidecar.Models; + +/// +/// The result of calling a downstream api. +/// +/// The status code of the response. +/// The headers of the response. +/// Optional. The content of the response. +public record DownstreamApiResult( + int StatusCode, + Dictionary> Headers, + string? Content); diff --git a/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs b/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs new file mode 100644 index 000000000..307df1311 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Models/ValidateAuthorizationHeaderResult.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace Microsoft.Identity.Web.Sidecar.Models; + +/// +/// The result of validation an authorization header. +/// +/// The protocol. +/// 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/OpenAPI/Microsoft.Identity.Web.Sidecar.json b/src/Microsoft.Identity.Web.Sidecar/OpenAPI/Microsoft.Identity.Web.Sidecar.json new file mode 100644 index 000000000..cfd4b36ad --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/OpenAPI/Microsoft.Identity.Web.Sidecar.json @@ -0,0 +1,940 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Microsoft.Identity.Web.Sidecar | v1", + "version": "1.0.0" + }, + "paths": { + "/Validate": { + "get": { + "tags": [ + "ValidateRequestEndpoints" + ], + "operationId": "ValidateAuthorizationHeader", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateAuthorizationHeaderResult" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/AuthorizationHeader/{apiName}": { + "get": { + "tags": [ + "AuthorizationHeaderEndpoint" + ], + "summary": "Get an authorization header for a configured downstream API.", + "description": "This endpoint will use the identity of the authenticated request to acquire an authorization header.Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. Examples:\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\nRepeat parameters like 'optionsOverride.Scopes' to add multiple scopes.", + "operationId": "AuthorizationHeader", + "parameters": [ + { + "name": "apiName", + "in": "path", + "description": "The downstream API to acquire an authorization header for.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "AgentIdentity", + "in": "query", + "description": "The identity of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUsername", + "in": "query", + "description": "The username (UPN) of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUserId", + "in": "query", + "description": "The Object ID of the agent (OID).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.Scopes", + "in": "query", + "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", + "style": "form", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RequestAppToken", + "in": "query", + "description": "true = acquire an app (client credentials) token instead of user token.", + "schema": { + "type": "boolean" + } + }, + { + "name": "optionsOverride.BaseUrl", + "in": "query", + "description": "Override downstream API base URL.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RelativePath", + "in": "query", + "description": "Override relative path appended to BaseUrl.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.HttpMethod", + "in": "query", + "description": "Override HTTP method (GET, POST, PATCH, etc.).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcceptHeader", + "in": "query", + "description": "Sets Accept header (e.g. application/json).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.ContentType", + "in": "query", + "description": "Sets Content-Type used for serialized body (if body provided).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Tenant", + "in": "query", + "description": "Override tenant (GUID or 'common').", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", + "in": "query", + "description": "boolean", + "schema": { + "type": "true = bypass token cache." + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Claims", + "in": "query", + "description": "JSON claims challenge or extra claims.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.CorrelationId", + "in": "query", + "description": "GUID correlation id for token acquisition.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.LongRunningWebApiSessionKey", + "in": "query", + "description": "Session key for long running OBO flows.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.FmiPath", + "in": "query", + "description": "Federated Managed Identity path (if using FMI).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.PopPublicKey", + "in": "query", + "description": "Public key or JWK for PoP / AT-POP requests.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId", + "in": "query", + "description": "Managed Identity client id (user-assigned).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationHeaderResult" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/AuthorizationHeaderUnauthenticated/{apiName}": { + "get": { + "tags": [ + "AuthorizationHeaderEndpoint" + ], + "summary": "Get an authorization header for a configured downstream API using this configured client credentials.", + "description": "This endpoint will use the configured client credentials to acquire an authorization header.Use dotted query parameters prefixed with 'optionsOverride.' to override call settings with respect to the configuration. Examples:\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\nRepeat parameters like 'optionsOverride.Scopes' to add multiple scopes.", + "operationId": "AuthorizationHeaderUnauthenticated", + "parameters": [ + { + "name": "apiName", + "in": "path", + "description": "The downstream API to acquire an authorization header for.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "AgentIdentity", + "in": "query", + "description": "The identity of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUsername", + "in": "query", + "description": "The username (UPN) of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUserId", + "in": "query", + "description": "The Object ID of the agent (OID).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.Scopes", + "in": "query", + "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", + "style": "form", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RequestAppToken", + "in": "query", + "description": "true = acquire an app (client credentials) token instead of user token.", + "schema": { + "type": "boolean" + } + }, + { + "name": "optionsOverride.BaseUrl", + "in": "query", + "description": "Override downstream API base URL.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RelativePath", + "in": "query", + "description": "Override relative path appended to BaseUrl.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.HttpMethod", + "in": "query", + "description": "Override HTTP method (GET, POST, PATCH, etc.).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcceptHeader", + "in": "query", + "description": "Sets Accept header (e.g. application/json).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.ContentType", + "in": "query", + "description": "Sets Content-Type used for serialized body (if body provided).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Tenant", + "in": "query", + "description": "Override tenant (GUID or 'common').", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", + "in": "query", + "description": "boolean", + "schema": { + "type": "true = bypass token cache." + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Claims", + "in": "query", + "description": "JSON claims challenge or extra claims.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.CorrelationId", + "in": "query", + "description": "GUID correlation id for token acquisition.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.LongRunningWebApiSessionKey", + "in": "query", + "description": "Session key for long running OBO flows.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.FmiPath", + "in": "query", + "description": "Federated Managed Identity path (if using FMI).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.PopPublicKey", + "in": "query", + "description": "Public key or JWK for PoP / AT-POP requests.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId", + "in": "query", + "description": "Managed Identity client id (user-assigned).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationHeaderResult" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/DownstreamApi/{apiName}": { + "post": { + "tags": [ + "DownstreamApiEndpoint" + ], + "summary": "Invoke a configured downstream API through the sidecar using the authenticated identity.", + "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Examples:\n ?optionsOverride.Scopes=User.Read\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default", + "operationId": "DownstreamApi", + "parameters": [ + { + "name": "apiName", + "in": "path", + "description": "The downstream API to call", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "AgentIdentity", + "in": "query", + "description": "The identity of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUsername", + "in": "query", + "description": "The username (UPN) of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUserId", + "in": "query", + "description": "The Object ID of the agent (OID).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.Scopes", + "in": "query", + "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", + "style": "form", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RequestAppToken", + "in": "query", + "description": "true = acquire an app (client credentials) token instead of user token.", + "schema": { + "type": "boolean" + } + }, + { + "name": "optionsOverride.BaseUrl", + "in": "query", + "description": "Override downstream API base URL.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RelativePath", + "in": "query", + "description": "Override relative path appended to BaseUrl.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.HttpMethod", + "in": "query", + "description": "Override HTTP method (GET, POST, PATCH, etc.).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcceptHeader", + "in": "query", + "description": "Sets Accept header (e.g. application/json).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.ContentType", + "in": "query", + "description": "Sets Content-Type used for serialized body (if body provided).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Tenant", + "in": "query", + "description": "Override tenant (GUID or 'common').", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", + "in": "query", + "description": "boolean", + "schema": { + "type": "true = bypass token cache." + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Claims", + "in": "query", + "description": "JSON claims challenge or extra claims.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.CorrelationId", + "in": "query", + "description": "GUID correlation id for token acquisition.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.LongRunningWebApiSessionKey", + "in": "query", + "description": "Session key for long running OBO flows.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.FmiPath", + "in": "query", + "description": "Federated Managed Identity path (if using FMI).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.PopPublicKey", + "in": "query", + "description": "Public key or JWK for PoP / AT-POP requests.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId", + "in": "query", + "description": "Managed Identity client id (user-assigned).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownstreamApiResult" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/DownstreamApiUnauthenticated/{apiName}": { + "post": { + "tags": [ + "DownstreamApiEndpoint" + ], + "summary": "Invoke a configured downstream API through the sidecar using the configured client credentials.", + "description": "Override downstream call options using dotted query parameters prefixed with 'optionsOverride.'. Examples:\n ?optionsOverride.Scopes=User.Read\n ?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read\n ?optionsOverride.AcquireTokenOptions.Tenant=GUID\n ?optionsOverride.RequestAppToken=true&optionsOverride.Scopes=https://graph.microsoft.com/.default", + "operationId": "DownstreamApiUnauthenticated", + "parameters": [ + { + "name": "apiName", + "in": "path", + "description": "The downstream API to call", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "AgentIdentity", + "in": "query", + "description": "The identity of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUsername", + "in": "query", + "description": "The username (UPN) of the agent.", + "schema": { + "type": "string" + } + }, + { + "name": "AgentUserId", + "in": "query", + "description": "The Object ID of the agent (OID).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.Scopes", + "in": "query", + "description": "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", + "style": "form", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RequestAppToken", + "in": "query", + "description": "true = acquire an app (client credentials) token instead of user token.", + "schema": { + "type": "boolean" + } + }, + { + "name": "optionsOverride.BaseUrl", + "in": "query", + "description": "Override downstream API base URL.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.RelativePath", + "in": "query", + "description": "Override relative path appended to BaseUrl.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.HttpMethod", + "in": "query", + "description": "Override HTTP method (GET, POST, PATCH, etc.).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcceptHeader", + "in": "query", + "description": "Sets Accept header (e.g. application/json).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.ContentType", + "in": "query", + "description": "Sets Content-Type used for serialized body (if body provided).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Tenant", + "in": "query", + "description": "Override tenant (GUID or 'common').", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ForceRefresh", + "in": "query", + "description": "boolean", + "schema": { + "type": "true = bypass token cache." + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.Claims", + "in": "query", + "description": "JSON claims challenge or extra claims.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.CorrelationId", + "in": "query", + "description": "GUID correlation id for token acquisition.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.LongRunningWebApiSessionKey", + "in": "query", + "description": "Session key for long running OBO flows.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.FmiPath", + "in": "query", + "description": "Federated Managed Identity path (if using FMI).", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.PopPublicKey", + "in": "query", + "description": "Public key or JWK for PoP / AT-POP requests.", + "schema": { + "type": "string" + } + }, + { + "name": "optionsOverride.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId", + "in": "query", + "description": "Managed Identity client id (user-assigned).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownstreamApiResult" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AuthorizationHeaderResult": { + "required": [ + "authorizationHeader" + ], + "type": "object", + "properties": { + "authorizationHeader": { + "type": "string" + } + } + }, + "DownstreamApiResult": { + "required": [ + "statusCode", + "headers", + "content" + ], + "type": "object", + "properties": { + "statusCode": { + "type": "integer", + "format": "int32" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "content": { + "type": "string", + "nullable": true + } + } + }, + "JsonNode": { }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + } + }, + "ValidateAuthorizationHeaderResult": { + "required": [ + "protocol", + "token", + "claims" + ], + "type": "object", + "properties": { + "protocol": { + "type": "string" + }, + "token": { + "type": "string" + }, + "claims": { + "$ref": "#/components/schemas/JsonNode" + } + } + } + } + }, + "tags": [ + { + "name": "ValidateRequestEndpoints" + }, + { + "name": "AuthorizationHeaderEndpoint" + }, + { + "name": "DownstreamApiEndpoint" + } + ] +} \ No newline at end of file diff --git a/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs b/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs new file mode 100644 index 000000000..1730f1284 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/OpenApiDescriptions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.Identity.Web.Sidecar; + +internal static class OpenApiDescriptions +{ + internal static void AddOptionsOverrideParameters(OpenApiOperation op) + { + // Scopes (repeatable) + op.Parameters.Add(new OpenApiParameter + { + Name = "optionsOverride.Scopes", + In = ParameterLocation.Query, + Description = "Repeatable. Each occurrence adds one scope. Example: optionsOverride.Scopes=User.Read", + Required = false, + Schema = new OpenApiSchema { Type = "string" }, + Explode = true, + Style = ParameterStyle.Form + }); + + // Core boolean / simple toggles + AddSimple(op, "optionsOverride.RequestAppToken", "boolean", "true = acquire an app (client credentials) token instead of user token."); + + // Base request shaping + AddSimple(op, "optionsOverride.BaseUrl", "string", "Override downstream API base URL."); + AddSimple(op, "optionsOverride.RelativePath", "string", "Override relative path appended to BaseUrl."); + AddSimple(op, "optionsOverride.HttpMethod", "string", "Override HTTP method (GET, POST, PATCH, etc.)."); + AddSimple(op, "optionsOverride.AcceptHeader", "string", "Sets Accept header (e.g. application/json)."); + AddSimple(op, "optionsOverride.ContentType", "string", "Sets Content-Type used for serialized body (if body provided)."); + + // AcquireTokenOptions.* (token acquisition tuning) + AddAcquireTokenOption(op, "Tenant", "Override tenant (GUID or 'common')."); + AddAcquireTokenOption(op, "ForceRefresh", "boolean", "true = bypass token cache."); + AddAcquireTokenOption(op, "Claims", "JSON claims challenge or extra claims."); + AddAcquireTokenOption(op, "CorrelationId", "GUID correlation id for token acquisition."); + AddAcquireTokenOption(op, "LongRunningWebApiSessionKey", "Session key for long running OBO flows."); + AddAcquireTokenOption(op, "FmiPath", "Federated Managed Identity path (if using FMI)."); + AddAcquireTokenOption(op, "PopPublicKey", "Public key or JWK for PoP / AT-POP requests."); + + // Managed Identity (if enabled) + AddAcquireTokenOption(op, "ManagedIdentity.UserAssignedClientId", "Managed Identity client id (user-assigned)."); + } + + private static void AddSimple(OpenApiOperation op, string name, string type, string desc) + { + op.Parameters.Add(new OpenApiParameter + { + Name = name, + In = ParameterLocation.Query, + Description = desc, + Required = false, + Schema = new OpenApiSchema { Type = type } + }); + } + + private static void AddAcquireTokenOption(OpenApiOperation op, string name, string description, string type = "string") + { + op.Parameters.Add(new OpenApiParameter + { + Name = $"optionsOverride.AcquireTokenOptions.{name}", + In = ParameterLocation.Query, + Description = description, + Required = false, + Schema = new OpenApiSchema { Type = type } + }); + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/OptionsOverrideSchemaTransformer.cs b/src/Microsoft.Identity.Web.Sidecar/OptionsOverrideSchemaTransformer.cs new file mode 100644 index 000000000..40ed15813 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/OptionsOverrideSchemaTransformer.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Microsoft.Identity.Web.Sidecar +{ + public class OptionsOverrideOperationTransformer : IOpenApiOperationTransformer + { + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + // Detect custom metadata (attribute example shown below). + var overrideMeta = context.Description.RelativePath?.Contains("AuthorizationHeader", StringComparison.InvariantCulture) == true || + context.Description.RelativePath?.Contains("Downstream", StringComparison.InvariantCulture) == true; + + if (!overrideMeta) + { + return Task.CompletedTask; + } + + OpenApiDescriptions.AddOptionsOverrideParameters(operation); + + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs new file mode 100644 index 000000000..ecc9ce23a --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Identity.Web.Sidecar.Endpoints; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Microsoft.Identity.Web.Sidecar; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateSlimBuilder(args); + + builder.Services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); + }); + + builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"), subscribeToJwtBearerMiddlewareDiagnosticsEvents: true) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + + ConfigureDataProtection(builder); + + // Add the agent identities and downstream APIs + builder.Services.AddAgentIdentities() + .AddDownstreamApis(builder.Configuration.GetSection("DownstreamApis")); + + // Health checks: + // Tag checks that should participate in readiness with "ready". + builder.Services.AddHealthChecks(); + + ConfigureAuthN(builder); + + builder.Services.AddAuthorization(); + + builder.Services.AddOpenApi(options => + { + options.AddOperationTransformer(new OptionsOverrideOperationTransformer()); + }); + + var app = builder.Build(); + + // Single endpoint for both liveness and readiness + // as no checks are performed as part of startup. + // httpGet: path: /health + app.MapHealthChecks("/healthz"); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + + app.AddValidateRequestEndpoints(); + app.AddAuthorizationHeaderRequestEndpoints(); + app.AddDownstreamApiRequestEndpoints(); + + app.SetNoCachingMiddleware(); + + app.Run(); + } + + private static void ConfigureAuthN(WebApplicationBuilder builder) + { + // Disable claims mapping. + JwtSecurityTokenHandler.DefaultMapInboundClaims = false; + JsonWebTokenHandler.DefaultMapInboundClaims = false; + builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, + options => + { + // Enable the right role claim type. + options.TokenValidationParameters.RoleClaimType = "roles"; + options.TokenValidationParameters.NameClaimType = "sub"; + }); + } + + private static void ConfigureDataProtection(WebApplicationBuilder builder) + { + var dataProtectionBuilder = builder.Services.AddDataProtection() + .SetApplicationName("Microsoft.Identity.Web.Sidecar"); + + // Configure based on environment + if (builder.Environment.IsProduction()) + { + // Production configuration for Linux containers + var keysPath = Environment.GetEnvironmentVariable("DATA_PROTECTION_KEYS_PATH") ?? "/app/keys"; + + // Ensure the directory exists + Directory.CreateDirectory(keysPath); + + dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(keysPath)); + + // Optional: Configure key encryption if certificate is available + var certPath = Environment.GetEnvironmentVariable("DATA_PROTECTION_CERT_PATH"); + if (!string.IsNullOrEmpty(certPath) && File.Exists(certPath)) + { + var certPassword = Environment.GetEnvironmentVariable("DATA_PROTECTION_CERT_PASSWORD"); +#pragma warning disable SYSLIB0057 // Type or member is obsolete, No overload for new API accepts a password. + var cert = new X509Certificate2(certPath, certPassword); +#pragma warning restore SYSLIB0057 // Type or member is obsolete + dataProtectionBuilder.ProtectKeysWithCertificate(cert); + } + } + else + { + // Development configuration + var keysPath = Path.Combine(builder.Environment.ContentRootPath, "keys"); + Directory.CreateDirectory(keysPath); + dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(keysPath)); + } + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Properties/launchSettings.json b/src/Microsoft.Identity.Web.Sidecar/Properties/launchSettings.json new file mode 100644 index 000000000..0bd01439f --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5178", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/openapi/v1.json", + "environmentVariables": { + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": false + } + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md new file mode 100644 index 000000000..d20bc226f --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/README.md @@ -0,0 +1,117 @@ +# Microsoft.Identity.Web.Sidecar + +## Overview + +`Microsoft.Identity.Web.Sidecar` hosts a minimal ASP.NET Core Web API that +enables Microsoft Entra token acquisition and downstream API calls, and token validation including for agents + +### Key capabilities + +- Validates incoming tokens and surfaces their claims. +- Decrypts tokens if applicable. +- Acquires User OBO or Application tokens for configured downstream APIs. + +## Configuration + +Settings are supplied via `appsettings.json`, environment variables, or any standard [ASP.NET Core configuration source](https://learn.microsoft.com/aspnet/core/fundamentals/configuration/). + +```jsonc +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "ClientCredentials": [ + { "SourceType": "...", } + ], + "AllowWebApiToBeAuthorizedByACL": true + }, + "DownstreamApis": { + "graph": { + "BaseUrl": "https://graph.microsoft.com/v1.0/", + "RelativePath": "me", + "Scopes": [ "User.Read" ] + } + }, + "TokenDecryptionCredentialsDescription" : [ + // If applicable + { "SourceType": "...", } + ] +} +``` + +*Important sections* + +- **AzureAd**: Standard Microsoft.Identity.Web web API registration; client credentials are optional if only delegated flows are required. +- **DownstreamApis**: Named profiles for endpoints resolved via `{apiName}`. +- **Data protection**: In production the app persists keys to `DATA_PROTECTION_KEYS_PATH` (default `/app/keys`) and optionally protects them with a certificate referenced via `DATA_PROTECTION_CERT_PATH` and `DATA_PROTECTION_CERT_PASSWORD`. + +## Running the sidecar + +### Prerequisites + +- .NET SDK 9.0 or later. +- A Microsoft Entra application registration for the sidecar and any downstream APIs. + +### Local execution + +```pwsh +dotnet restore +dotnet run -f net9.0 +``` + +### Containers + +- [Dockerfile](./Dockerfile) is used for building images within Visual Studio +- [DockerFile.NanoServer](./DockerFile.NanoServer) is used for building a nanoserver image from previously build binaries +- [DockerFile.AzureLinux](./Dockerfile.AzureLinux) is used for building an azure linux 3.0 image from previously build binaries + +## HTTP surface + +| Endpoint | Method | Auth | Description | +| ----------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ | +| `/Validate` | GET | Required | Returns the raw bearer token and claims. 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. | +| `/DownstreamApiUnauthenticated/{apiName}` | POST | Optional | Invokes the downstream API using the sidecar’s application identity. | +| `/healthz` | GET | NA | Combined liveness/readiness check. | +| `/openapi/v1.json` | GET | NA | When ASPNETCORE_ENVIRONMENT=Development | + +Complete documentation is provided [here](./OpenAPI/Microsoft.Identity.Web.Sidecar.json) + +### Options overrides + +All token-acquisition endpoints accept dotted query parameters prefixed with `optionsOverride.`; they merge into a `DownstreamApis` profile through [`BindableDownstreamApiOptions`](Models/BindableDownstreamApiOptions.cs). + +Examples: +- `?optionsOverride.Scopes=User.Read&optionsOverride.Scopes=Mail.Read` +- `?optionsOverride.RequestAppToken=true` +- `?optionsOverride.AcquireTokenOptions.Tenant=` +- `?optionsOverride.RelativePath=me/messages` + +Agent impersonation hints: +- `AgentIdentity=` +- `AgentUsername=upn@contoso.com` +- `AgentUserId=` + +### Response contract + +- `/AuthorizationHeader*` returns `{ "authorizationHeader": "Bearer ey..." }`. +- `/DownstreamApi*` returns `{ "statusCode": 200, "headers": { ... }, "content": "..." }`. +- `/Validate` returns `{ "protocol": "Bearer", "token": "ey...", "claims": { ... } }`. + +## 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. + +## Runtime composition + +| Concern | Implementation | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Authentication & authorization | [`Program`](Program.cs) wires `AddMicrosoftIdentityWebApi`, optional scope enforcement, and agent identity overrides. | +| 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. | + diff --git a/src/Microsoft.Identity.Web.Sidecar/appsettings.Development.json b/src/Microsoft.Identity.Web.Sidecar/appsettings.Development.json new file mode 100644 index 000000000..3e1a225ad --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/appsettings.json b/src/Microsoft.Identity.Web.Sidecar/appsettings.json new file mode 100644 index 000000000..3ceacc805 --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/appsettings.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://raw.githubusercontent.com/AzureAD/microsoft-identity-web/refs/heads/master/JsonSchemas/microsoft-identity-web.json", + /* +The following identity settings need to be configured +before the project can be successfully executed. +For more info see https://aka.ms/dotnet-template-ms-identity-platform +*/ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", // f645ad92-e38d-4d1a-b510-d1b09a74a8ca + "ClientId": "", //"556d438d-2f4b-4add-9713-ede4e5f5d7da" + + // "Scopes": "" // access_as_user + + //"ClientCredentials": [ + // { + // "SourceType": "StoreWithDistinguishedName", + // "CertificateStorePath": "LocalMachine/My", + // "CertificateDistinguishedName": "CN=LabAuth.MSIDLab.com" + // } + //], + + "AllowWebApiToBeAuthorizedByACL": true + }, + + //"DownstreamApis": { + // "me": { + // "BaseUrl": "https://graph.microsoft.com/v1.0/", + // "RelativePath": "me", + // "Scopes": [ "User.Read" ] + // } + //}, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} + + diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs b/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs index 5cd85d0ef..a44e7bbcd 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs @@ -19,3 +19,4 @@ [assembly: InternalsVisibleTo("TokenAcquirerTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] [assembly: InternalsVisibleTo("GenerateMergeOptionsMethods, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] +[assembly: InternalsVisibleTo("Microsoft.Identity.Web.SideCar, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] diff --git a/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py b/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py new file mode 100644 index 000000000..83da2a94e --- /dev/null +++ b/tests/DevApps/SidecarAdapter/python/MicrosoftIdentityWebSidecarClient.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Iterable, Mapping, MutableMapping, Optional, Sequence +from urllib.parse import urljoin + +import requests + + +JsonDict = Dict[str, Any] + + +@dataclass(frozen=True) +class ProblemDetails: + """Represents the RFC 7807 problem details payload returned by the sidecar.""" + + type: Optional[str] + title: Optional[str] + status: Optional[int] + detail: Optional[str] + instance: Optional[str] + + @staticmethod + def from_dict(data: Mapping[str, Any]) -> "ProblemDetails": + return ProblemDetails( + type=data.get("type"), + title=data.get("title"), + status=data.get("status"), + detail=data.get("detail"), + instance=data.get("instance"), + ) + + +@dataclass(frozen=True) +class AuthorizationHeaderResult: + authorization_header: str + + @staticmethod + def from_dict(data: Mapping[str, Any]) -> "AuthorizationHeaderResult": + return AuthorizationHeaderResult(authorization_header=data["authorizationHeader"]) + + +@dataclass(frozen=True) +class DownstreamApiResult: + status_code: int + headers: Mapping[str, Any] + content: Any + + @staticmethod + def from_dict(data: Mapping[str, Any]) -> "DownstreamApiResult": + return DownstreamApiResult( + status_code=data["statusCode"], + headers=data.get("headers", {}), + content=data.get("content"), + ) + + +@dataclass(frozen=True) +class ValidateAuthorizationHeaderResult: + protocol: str + token: str + claims: Mapping[str, Any] + + @staticmethod + def from_dict(data: Mapping[str, Any]) -> "ValidateAuthorizationHeaderResult": + return ValidateAuthorizationHeaderResult( + protocol=data["protocol"], + token=data["token"], + claims=data.get("claims", {}), + ) + + +@dataclass(frozen=True) +class AcquireTokenOptions: + tenant: Optional[str] = None + force_refresh: Optional[bool] = None + claims: Optional[str] = None + correlation_id: Optional[str] = None + long_running_web_api_session_key: Optional[str] = None + fmi_path: Optional[str] = None + pop_public_key: Optional[str] = None + managed_identity_user_assigned_client_id: Optional[str] = None + + +@dataclass(frozen=True) +class SidecarCallOptions: + scopes: Optional[Sequence[str]] = None + request_app_token: Optional[bool] = None + base_url: Optional[str] = None + relative_path: Optional[str] = None + http_method: Optional[str] = None + accept_header: Optional[str] = None + content_type: Optional[str] = None + acquire_token_options: Optional[AcquireTokenOptions] = None + + +class SidecarError(Exception): + """Raised when the sidecar returns an error response.""" + + def __init__(self, status_code: int, message: str, problem_details: Optional[ProblemDetails] = None) -> None: + super().__init__(message) + self.status_code = status_code + self.problem_details = problem_details + + +class MicrosoftIdentityWebSidecarClient: + """Client for the Microsoft.Identity.Web.Sidecar endpoints.""" + + def __init__( + self, + base_url: str, + *, + session: Optional[requests.Session] = None, + default_headers: Optional[Mapping[str, str]] = None, + timeout: Optional[float] = 30.0, + ) -> None: + self._base_url = base_url.rstrip("/") + "/" + self._session = session or requests.Session() + self._owns_session = session is None + self._default_headers: Dict[str, str] = dict(default_headers or {}) + self._timeout = timeout + + def close(self) -> None: + if self._owns_session: + self._session.close() + + def __enter__(self) -> "MicrosoftIdentityWebSidecarClient": + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] + self.close() + + def validate_authorization_header(self, authorization_header: str) -> ValidateAuthorizationHeaderResult: + response_data = self._send_json( + method="GET", + path="Validate", + headers={"Authorization": authorization_header}, + ) + return ValidateAuthorizationHeaderResult.from_dict(response_data) + + def get_authorization_header( + self, + api_name: str, + authorization_header: str, + *, + agent_identity: Optional[str] = None, + agent_username: Optional[str] = None, + agent_user_id: Optional[str] = None, + options: Optional[SidecarCallOptions] = None, + ) -> AuthorizationHeaderResult: + params = self._build_query_parameters(agent_identity, agent_username, agent_user_id, options) + response_data = self._send_json( + method="GET", + path=f"AuthorizationHeader/{api_name}", + headers={"Authorization": authorization_header}, + params=params, + ) + return AuthorizationHeaderResult.from_dict(response_data) + + def get_authorization_header_unauthenticated( + self, + api_name: str, + *, + agent_identity: Optional[str] = None, + agent_username: Optional[str] = None, + agent_user_id: Optional[str] = None, + options: Optional[SidecarCallOptions] = None, + ) -> AuthorizationHeaderResult: + params = self._build_query_parameters(agent_identity, agent_username, agent_user_id, options) + response_data = self._send_json( + method="GET", + path=f"AuthorizationHeaderUnauthenticated/{api_name}", + params=params, + ) + return AuthorizationHeaderResult.from_dict(response_data) + + def invoke_downstream_api( + self, + api_name: str, + authorization_header: str, + *, + agent_identity: Optional[str] = None, + agent_username: Optional[str] = None, + agent_user_id: Optional[str] = None, + options: Optional[SidecarCallOptions] = None, + json_body: Any = None, + ) -> DownstreamApiResult: + params = self._build_query_parameters(agent_identity, agent_username, agent_user_id, options) + response_data = self._send_json( + method="POST", + path=f"DownstreamApi/{api_name}", + headers={"Authorization": authorization_header}, + params=params, + json=json_body, + ) + return DownstreamApiResult.from_dict(response_data) + + def invoke_downstream_api_unauthenticated( + self, + api_name: str, + *, + agent_identity: Optional[str] = None, + agent_username: Optional[str] = None, + agent_user_id: Optional[str] = None, + options: Optional[SidecarCallOptions] = None, + json_body: Any = None, + ) -> DownstreamApiResult: + params = self._build_query_parameters(agent_identity, agent_username, agent_user_id, options) + response_data = self._send_json( + method="POST", + path=f"DownstreamApiUnauthenticated/{api_name}", + params=params, + json=json_body, + ) + return DownstreamApiResult.from_dict(response_data) + + def with_default_authorization(self, authorization_header: str) -> "MicrosoftIdentityWebSidecarClient": + """Return a new client instance that always sends the given Authorization header.""" + + headers = dict(self._default_headers) + headers["Authorization"] = authorization_header + return MicrosoftIdentityWebSidecarClient( + self._base_url, + session=self._session, + default_headers=headers, + timeout=self._timeout, + ) + + def _build_query_parameters( + self, + agent_identity: Optional[str], + agent_username: Optional[str], + agent_user_id: Optional[str], + options: Optional[SidecarCallOptions], + ) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if agent_identity: + params["AgentIdentity"] = agent_identity + if agent_username: + params["AgentUsername"] = agent_username + if agent_user_id: + params["AgentUserId"] = agent_user_id + + if options: + if options.scopes: + params["optionsOverride.Scopes"] = list(options.scopes) + if options.request_app_token is not None: + params["optionsOverride.RequestAppToken"] = _to_bool_str(options.request_app_token) + if options.base_url: + params["optionsOverride.BaseUrl"] = options.base_url + if options.relative_path: + params["optionsOverride.RelativePath"] = options.relative_path + if options.http_method: + params["optionsOverride.HttpMethod"] = options.http_method + if options.accept_header: + params["optionsOverride.AcceptHeader"] = options.accept_header + if options.content_type: + params["optionsOverride.ContentType"] = options.content_type + + if options.acquire_token_options: + acquire_options = options.acquire_token_options + if acquire_options.tenant: + params["optionsOverride.AcquireTokenOptions.Tenant"] = acquire_options.tenant + if acquire_options.force_refresh is not None: + params[ + "optionsOverride.AcquireTokenOptions.ForceRefresh" + ] = _to_bool_str(acquire_options.force_refresh) + if acquire_options.claims: + params["optionsOverride.AcquireTokenOptions.Claims"] = acquire_options.claims + if acquire_options.correlation_id: + params[ + "optionsOverride.AcquireTokenOptions.CorrelationId" + ] = acquire_options.correlation_id + if acquire_options.long_running_web_api_session_key: + params[ + "optionsOverride.AcquireTokenOptions.LongRunningWebApiSessionKey" + ] = acquire_options.long_running_web_api_session_key + if acquire_options.fmi_path: + params["optionsOverride.AcquireTokenOptions.FmiPath"] = acquire_options.fmi_path + if acquire_options.pop_public_key: + params[ + "optionsOverride.AcquireTokenOptions.PopPublicKey" + ] = acquire_options.pop_public_key + if acquire_options.managed_identity_user_assigned_client_id: + params[ + "optionsOverride.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId" + ] = acquire_options.managed_identity_user_assigned_client_id + return params + + def _send_json( + self, + *, + method: str, + path: str, + headers: Optional[Mapping[str, str]] = None, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + ) -> JsonDict: + response = self._send( + method=method, + path=path, + headers=headers, + params=params, + json=json, + ) + try: + return response.json() + except ValueError as exc: + raise SidecarError(response.status_code, "Expected JSON response from sidecar") from exc + + def _send( + self, + *, + method: str, + path: str, + headers: Optional[Mapping[str, str]] = None, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + ) -> requests.Response: + url = urljoin(self._base_url, path) + request_headers: MutableMapping[str, str] = dict(self._default_headers) + if headers: + request_headers.update(headers) + + prepared_params = _prepare_params(params) + + response = self._session.request( + method=method, + url=url, + headers=request_headers, + params=prepared_params, + json=json, + timeout=self._timeout, + ) + if response.status_code >= 400: + self._raise_sidecar_error(response) + return response + + def _raise_sidecar_error(self, response: requests.Response) -> None: + problem_details: Optional[ProblemDetails] = None + message = f"Sidecar request failed with status code {response.status_code}" + try: + data = response.json() + except ValueError: + pass + else: + if isinstance(data, Mapping): + problem_details = ProblemDetails.from_dict(data) + detail = problem_details.detail or problem_details.title + if detail: + message = detail + raise SidecarError(response.status_code, message, problem_details) + + +def _prepare_params(params: Optional[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]: + if not params: + return params + prepared: Dict[str, Any] = {} + for key, value in params.items(): + if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): + prepared[key] = list(value) + else: + prepared[key] = value + return prepared + + +def _to_bool_str(value: bool) -> str: + return "true" if value else "false" diff --git a/tests/DevApps/SidecarAdapter/python/README.md b/tests/DevApps/SidecarAdapter/python/README.md new file mode 100644 index 000000000..1ff45fd38 --- /dev/null +++ b/tests/DevApps/SidecarAdapter/python/README.md @@ -0,0 +1,50 @@ +# Sidecar Adapter Python + +This folder contains helper scripts for interacting with the Microsoft Identity Web Sidecar from Python. + +## Requirements + +- [Install UV](https://astral.sh/uv) + +## Contents + +- `MicrosoftIdentityWebSidecarClient.py` – Typed client covering the Sidecar's `/Validate`, `/AuthorizationHeader`, and `/DownstreamApi` endpoints. +- `main.py` – Command-line harness that exercises the client and prints JSON responses. +- `get_token.py` – Helper for obtaining a user token via MSAL. +``` + +## Usage + +Display the available commands: + +```sh +uv run --with requests main.py --help +``` + +The examples depend on setting these variables + +```sh +$side_car_url = "" +# Example values, use appropriate values for the token you want to request. +$token = uv run --with msal get_token.py --client-id "f79f9db9-c582-4b7b-9d4c-0e8fd40623f0" --authority "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca" --scope "api://556d438d-2f4b-4add-9713-ede4e5f5d7da/access_as_user" +``` + +Example: validate an authorization header returned by `get_token.py`: + +```sh +uv run --with requests main.py --base-url $side_car_url --authorization-header "Bearer $token" validate +``` + +Invoke a downstream API by name, supplying an override scope and a JSON payload stored in `body.json`: + +```sh +uv run --with requests main.py --base-url $side_car_url --authorization-header "Bearer $token" --scope invoke-downstream --body-file +``` + +Invoke a downstream API by name, use the credentials configured by the application: + +```sh +uv run --with requests main.py --base-url=$side_car_url --agent-username= --agent-identity= invoke-downstream-unauth me +``` + +For client-credential flows, omit `--authorization-header` and use the unauthenticated commands such as `get-auth-header-unauth` or `invoke-downstream-unauth`. diff --git a/tests/DevApps/SidecarAdapter/python/get_token.py b/tests/DevApps/SidecarAdapter/python/get_token.py new file mode 100644 index 000000000..5fae1ecd4 --- /dev/null +++ b/tests/DevApps/SidecarAdapter/python/get_token.py @@ -0,0 +1,64 @@ +import argparse +import os + +from msal import PublicClientApplication, SerializableTokenCache + +# Persistent token cache +cache = SerializableTokenCache() + +# Load cache from file if exists +if os.path.exists("token_cache.bin"): + cache.deserialize(open("token_cache.bin", "r").read()) + +parser = argparse.ArgumentParser( + description="Acquire a token using MSAL with a persistent cache." +) +parser.add_argument( + "--client-id", + required=True, + help="The application (client) ID registered in Azure AD." +) +parser.add_argument( + "--authority", + required=True, + help="The authority URL, e.g. https://login.microsoftonline.com/." +) +parser.add_argument( + "--scope", + required=True, + help="The scope for the access token." +) +args = parser.parse_args() + +client_id = args.client_id +authority = args.authority +scope = args.scope + +app = PublicClientApplication( + client_id=client_id, + authority=authority, + token_cache=cache +) + +# Try silent acquisition first +accounts = app.get_accounts() +result = None + +if accounts: + result = app.acquire_token_silent( + scopes=[scope], + account=accounts[0] + ) + +if not result: + result = app.acquire_token_interactive(scopes=[scope]) + +# Save cache after acquisition +if cache.has_state_changed: + with open("token_cache.bin", "w") as cache_file: + cache_file.write(cache.serialize()) + +if (result): + print(result["access_token"]) +else: + print("Failed to acquire token:", result.get("error"), result.get("error_description")) diff --git a/tests/DevApps/SidecarAdapter/python/main.py b/tests/DevApps/SidecarAdapter/python/main.py new file mode 100644 index 000000000..786610f46 --- /dev/null +++ b/tests/DevApps/SidecarAdapter/python/main.py @@ -0,0 +1,351 @@ +import argparse +import json +from pathlib import Path +from typing import Any, Optional + +from MicrosoftIdentityWebSidecarClient import ( + AcquireTokenOptions, + MicrosoftIdentityWebSidecarClient, + SidecarCallOptions, + SidecarError, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Exercise the Microsoft Identity Web Sidecar client against a running sidecar instance.", + ) + parser.add_argument( + "--base-url", + required=True, + help="Fully qualified base URL for the sidecar (e.g. https://localhost:5001/sidecar).", + ) + parser.add_argument( + "--authorization-header", + help="Authorization header to send for authenticated endpoints (e.g. 'Bearer ').", + ) + parser.add_argument( + "--agent-identity", + help="Optional AgentIdentity query parameter for the sidecar call.", + ) + parser.add_argument( + "--agent-username", + help="Optional AgentUsername query parameter for the sidecar call.", + ) + parser.add_argument( + "--agent-user-id", + help="Optional AgentUserId query parameter for the sidecar call.", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("validate", help="Validate an authorization header using the /Validate endpoint.") + + auth_header_parser = subparsers.add_parser( + "get-auth-header", + help="Call /AuthorizationHeader/{apiName}.", + ) + auth_header_parser.add_argument("api_name", help="Configured API name defined in the sidecar configuration.") + _augment_with_options_override(auth_header_parser) + + auth_header_unauth_parser = subparsers.add_parser( + "get-auth-header-unauth", help="Call /AuthorizationHeaderUnauthenticated/{apiName}." + ) + auth_header_unauth_parser.add_argument( + "api_name", help="Configured API name defined in the sidecar configuration." + ) + _augment_with_options_override(auth_header_unauth_parser) + + downstream_parser = subparsers.add_parser( + "invoke-downstream", + help="Call /DownstreamApi/{apiName} with an optional JSON body.", + ) + downstream_parser.add_argument("api_name", help="Configured API name defined in the sidecar configuration.") + downstream_parser.add_argument( + "--body-json", + help="Inline JSON payload to POST to the downstream API.", + ) + downstream_parser.add_argument( + "--body-file", + type=Path, + help="Path to a JSON file to POST to the downstream API.", + ) + _augment_with_options_override(downstream_parser) + + downstream_unauth_parser = subparsers.add_parser( + "invoke-downstream-unauth", + help="Call /DownstreamApiUnauthenticated/{apiName} with an optional JSON body.", + ) + downstream_unauth_parser.add_argument("api_name", help="Configured API name defined in the sidecar configuration.") + downstream_unauth_parser.add_argument( + "--body-json", + help="Inline JSON payload to POST to the downstream API.", + ) + downstream_unauth_parser.add_argument( + "--body-file", + type=Path, + help="Path to a JSON file to POST to the downstream API.", + ) + _augment_with_options_override(downstream_unauth_parser) + + return parser.parse_args() + + +def _augment_with_options_override(subparser: argparse.ArgumentParser) -> None: + subparser.add_argument( + "--scope", + dest="scopes", + action="append", + help="Repeatable. Adds a scope to optionsOverride.Scopes.", + ) + subparser.add_argument( + "--request-app-token", + dest="request_app_token", + action="store_const", + const=True, + default=None, + help="Set optionsOverride.RequestAppToken=true.", + ) + subparser.add_argument( + "--base-url-override", + dest="override_base_url", + help="Sets optionsOverride.BaseUrl.", + ) + subparser.add_argument( + "--relative-path", + dest="relative_path", + help="Sets optionsOverride.RelativePath.", + ) + subparser.add_argument( + "--http-method", + dest="http_method", + help="Sets optionsOverride.HttpMethod.", + ) + subparser.add_argument( + "--accept-header", + dest="accept_header", + help="Sets optionsOverride.AcceptHeader.", + ) + subparser.add_argument( + "--content-type", + dest="content_type", + help="Sets optionsOverride.ContentType.", + ) + subparser.add_argument( + "--tenant", + dest="tenant", + help="Sets optionsOverride.AcquireTokenOptions.Tenant.", + ) + subparser.add_argument( + "--force-refresh", + dest="force_refresh", + action="store_const", + const=True, + default=None, + help="Sets optionsOverride.AcquireTokenOptions.ForceRefresh=true.", + ) + subparser.add_argument( + "--claims", + dest="claims", + help="Sets optionsOverride.AcquireTokenOptions.Claims.", + ) + subparser.add_argument( + "--correlation-id", + dest="correlation_id", + help="Sets optionsOverride.AcquireTokenOptions.CorrelationId.", + ) + subparser.add_argument( + "--long-running-session-key", + dest="long_running_session_key", + help="Sets optionsOverride.AcquireTokenOptions.LongRunningWebApiSessionKey.", + ) + subparser.add_argument( + "--fmi-path", + dest="fmi_path", + help="Sets optionsOverride.AcquireTokenOptions.FmiPath.", + ) + subparser.add_argument( + "--pop-public-key", + dest="pop_public_key", + help="Sets optionsOverride.AcquireTokenOptions.PopPublicKey.", + ) + subparser.add_argument( + "--managed-identity-client-id", + dest="managed_identity_client_id", + help="Sets optionsOverride.AcquireTokenOptions.ManagedIdentity.UserAssignedClientId.", + ) + + +def build_call_options(args: argparse.Namespace) -> Optional[SidecarCallOptions]: + if not any( + getattr(args, attr, None) + for attr in ( + "scopes", + "request_app_token", + "override_base_url", + "relative_path", + "http_method", + "accept_header", + "content_type", + "tenant", + "force_refresh", + "claims", + "correlation_id", + "long_running_session_key", + "fmi_path", + "pop_public_key", + "managed_identity_client_id", + ) + ): + return None + + acquire_options = AcquireTokenOptions( + tenant=getattr(args, "tenant", None), + force_refresh=getattr(args, "force_refresh", None), + claims=getattr(args, "claims", None), + correlation_id=getattr(args, "correlation_id", None), + long_running_web_api_session_key=getattr(args, "long_running_session_key", None), + fmi_path=getattr(args, "fmi_path", None), + pop_public_key=getattr(args, "pop_public_key", None), + managed_identity_user_assigned_client_id=getattr(args, "managed_identity_client_id", None), + ) + + if not any( + getattr(acquire_options, field) + is not None + for field in ( + "tenant", + "force_refresh", + "claims", + "correlation_id", + "long_running_web_api_session_key", + "fmi_path", + "pop_public_key", + "managed_identity_user_assigned_client_id", + ) + ): + acquire_options = None + + return SidecarCallOptions( + scopes=getattr(args, "scopes", None), + request_app_token=getattr(args, "request_app_token", None), + base_url=getattr(args, "override_base_url", None), + relative_path=getattr(args, "relative_path", None), + http_method=getattr(args, "http_method", None), + accept_header=getattr(args, "accept_header", None), + content_type=getattr(args, "content_type", None), + acquire_token_options=acquire_options, + ) + + +def _resolve_json_body(args: argparse.Namespace) -> Optional[Any]: + if getattr(args, "body_json", None): + return json.loads(args.body_json) + if getattr(args, "body_file", None): + data = args.body_file.read_text(encoding="utf-8") + return json.loads(data) + return None + + +def ensure_authorization_header(args: argparse.Namespace) -> str: + if not args.authorization_header: + raise SystemExit("This command requires --authorization-header.") + return args.authorization_header + + +def main() -> None: + args = parse_args() + options = build_call_options(args) + + try: + with MicrosoftIdentityWebSidecarClient(args.base_url) as client: + if args.command == "validate": + authorization_header = ensure_authorization_header(args) + result = client.validate_authorization_header(authorization_header) + _print_json({ + "protocol": result.protocol, + "token": result.token, + "claims": result.claims, + }) + elif args.command == "get-auth-header": + authorization_header = ensure_authorization_header(args) + result = client.get_authorization_header( + args.api_name, + authorization_header, + agent_identity=args.agent_identity, + agent_username=args.agent_username, + agent_user_id=args.agent_user_id, + options=options, + ) + _print_json({"authorizationHeader": result.authorization_header}) + elif args.command == "get-auth-header-unauth": + result = client.get_authorization_header_unauthenticated( + args.api_name, + agent_identity=args.agent_identity, + agent_username=args.agent_username, + agent_user_id=args.agent_user_id, + options=options, + ) + _print_json({"authorizationHeader": result.authorization_header}) + elif args.command == "invoke-downstream": + authorization_header = ensure_authorization_header(args) + body = _resolve_json_body(args) + result = client.invoke_downstream_api( + args.api_name, + authorization_header, + agent_identity=args.agent_identity, + agent_username=args.agent_username, + agent_user_id=args.agent_user_id, + options=options, + json_body=body, + ) + _print_json({ + "statusCode": result.status_code, + "headers": result.headers, + "content": result.content, + }) + elif args.command == "invoke-downstream-unauth": + body = _resolve_json_body(args) + result = client.invoke_downstream_api_unauthenticated( + args.api_name, + agent_identity=args.agent_identity, + agent_username=args.agent_username, + agent_user_id=args.agent_user_id, + options=options, + json_body=body, + ) + _print_json({ + "statusCode": result.status_code, + "headers": result.headers, + "content": result.content, + }) + else: + raise SystemExit(f"Unsupported command: {args.command}") + except SidecarError as sidecar_error: + _handle_sidecar_error(sidecar_error) + + +def _print_json(payload: Any) -> None: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + +def _handle_sidecar_error(error: SidecarError) -> None: + details: dict[str, Any] = { + "statusCode": error.status_code, + } + if error.problem_details: + details["problemDetails"] = { + "type": error.problem_details.type, + "title": error.problem_details.title, + "status": error.problem_details.status, + "detail": error.problem_details.detail, + "instance": error.problem_details.instance, + } + else: + details["message"] = str(error) + print(json.dumps(details, indent=2, ensure_ascii=False)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 102de45ef..68f54478d 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -38,6 +38,7 @@ 1.0.2 4.3.4 4.3.1 + 4.18.4 diff --git a/tests/E2E Tests/Sidecar.Tests/AuthorizationHeaderEndpointTests.cs b/tests/E2E Tests/Sidecar.Tests/AuthorizationHeaderEndpointTests.cs new file mode 100644 index 000000000..5b8a47b8a --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/AuthorizationHeaderEndpointTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Moq; +using Xunit; + +namespace Sidecar.Tests; + +public class AuthorizationHeaderEndpointTests(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact] + public async Task AuthorizationHeader_WithInvalidToken_ReturnsUnauthorizedAsync() + { + // Arrange + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + + // Act + var response = await client.GetAsync("/AuthorizationHeader/test-api?OptionsOverride.Scopes=scopes"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task AuthorizationHeader_WithNonExistentApi_AndNoScope_OverrideReturnsBadRequestAsync() + { + // Arrange + var mockHeaderProvider = new TestAuthorizationHeaderProvider(); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(mockHeaderProvider); + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + }); + }).CreateClient(); + + // Add a valid token (mocked) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.GetAsync("/AuthorizationHeader/non-existent-api"); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("No scopes found", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AuthorizationHeader_UnknownService_ReturnsBadRequestAsync() + { + // Arrange + var mockHeaderProvider = new TestAuthorizationHeaderProvider(); + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(mockHeaderProvider); + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + }); + builder.UseSetting("DownstreamApi:test-api:BaseUrl", "https://api.example.com"); + // Don't set scopes to trigger the error + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.GetAsync("/AuthorizationHeader/unknown"); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task AuthorizationHeader_WithValidApiButNoScopes_ReturnsBadRequestAsync() + { + // Arrange + var mockHeaderProvider = new TestAuthorizationHeaderProvider(); + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(mockHeaderProvider); + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + }); + builder.UseSetting("DownstreamApi:test-api:BaseUrl", "https://api.example.com"); + // Don't set scopes to trigger the error + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.GetAsync("/AuthorizationHeader/test-api"); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("No scopes found for the API 'test-api'", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AuthorizationHeader_ThrowsException_Returns500Async() + { + // Arrange + var mockDownstreamApi = new Mock(); + var exception = new InvalidOperationException(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(exception); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.GetAsync("/AuthorizationHeader/test-api"); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + +} diff --git a/tests/E2E Tests/Sidecar.Tests/DownstreamApiEndpointTests.cs b/tests/E2E Tests/Sidecar.Tests/DownstreamApiEndpointTests.cs new file mode 100644 index 000000000..20b136682 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/DownstreamApiEndpointTests.cs @@ -0,0 +1,634 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Sidecar.Models; +using Moq; +using Xunit; + +namespace Sidecar.Tests; + +public class DownstreamApiEndpointTests(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact] + public async Task DownstreamApi_WithoutAuthentication_ReturnsUnauthorizedAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task DownstreamApi_WithValidApiButNoScopes_ReturnsBadRequestAsync() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + // Configure a downstream API without scopes + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + // Intentionally not setting Scopes + }); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("No scopes found for the API 'test-api'", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApi_WithValidApiButNoScopesInOptionsOverride_ReturnsBadRequestAsync() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + // Configure a downstream API without scopes + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.RelativePath = "/test"; + // Intentionally not setting Scopes + }); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + var optionsOverride = new DownstreamApiOptions + { + // Not setting Scopes + RelativePath = "/override" + }; + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("No scopes found for the API 'test-api' or in optionsOverride", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApi_WithMicrosoftIdentityWebChallengeUserException_ReturnsUnauthorizedAsync() + { + // Arrange + var mockDownstreamApi = new Mock(); + var msalException = new MsalUiRequiredException("AADSTS50076", "Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access."); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new MicrosoftIdentityWebChallengeUserException(msalException, ["user.read"])); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Due to a configuration change made by your administrator", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApi_WithGenericException_ReturnsInternalServerErrorAsync() + { + // Arrange + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Unexpected error")); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("An unexpected error occurred", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApi_WithSuccessfulCall_ReturnsOkWithDownstreamApiResultAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(200, result.StatusCode); + Assert.NotNull(result.Headers); + Assert.Equal(responseContent, result.Content); + } + + [Fact] + public async Task DownstreamApi_WithEmptyResponseContent_ReturnsOkWithNullContentAsync() + { + // Arrange + var mockResponse = new HttpResponseMessage(HttpStatusCode.NoContent) + { + Content = new StringContent("", Encoding.UTF8) + }; + mockResponse.Content.Headers.ContentLength = 0; + + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(204, result.StatusCode); + Assert.Null(result.Content); + } + + [Fact] + public async Task DownstreamApi_WithRequestBody_PassesContentCorrectlyAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var requestContent = new StringContent("{\"request\": \"value\"}", Encoding.UTF8, "application/json"); + + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + HttpContent? capturedContent = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, content, _) => + { + capturedContent = content; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/test-api", requestContent); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equivalent(capturedContent, requestContent); + } + + [Fact] + public async Task DownstreamApi_WithAgentIdentity_PassesAgentIdentityToOptionsAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + DownstreamApiOptions? capturedOptions = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((options, _, _, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + var agentIdentity = "test-agent-id"; + + // Act + var response = await client.PostAsync($"/DownstreamApi/test-api?agentidentity={agentIdentity}", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_FMI_PATH_FOR_CLIENT_ASSERTION")); + } + + [Fact] + public async Task DownstreamApi_WithAgentIdentityAndUsername_PassesAgentUserIdentityToOptionsAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + DownstreamApiOptions? capturedOptions = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((options, _, _, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + var agentIdentity = "test-agent-id"; + var agentUsername = "test-user@example.com"; + + // Act + var response = await client.PostAsync($"/DownstreamApi/test-api?agentidentity={agentIdentity}&agentUsername={agentUsername}", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_AGENT_IDENTITY")); + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_USERNAME")); + } + + [Fact] + public async Task DownstreamApi_WithAgentIdentityAndUserId_PassesAgentUserIdentityToOptionsAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + DownstreamApiOptions? capturedOptions = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((options, _, _, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + var agentIdentity = "test-agent-id"; + var agentUserId = "d75c4739-595d-44ed-a0b8-5176ca033c23"; + + // Act + var response = await client.PostAsync($"/DownstreamApi/test-api?agentidentity={agentIdentity}&agentUserId={agentUserId}", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_AGENT_IDENTITY")); + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_USER_ID")); + } + + [Fact] + public async Task DownstreamApi_WithAgentIdentityAndUserIdAndUsername_UsernameIsUsed() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + DownstreamApiOptions? capturedOptions = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((options, _, _, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + var agentIdentity = "test-agent-id"; + var agentUserId = "d75c4739-595d-44ed-a0b8-5176ca033c23"; + var agentUsername = "test-user@example.com"; + + // Act + var response = await client.PostAsync($"/DownstreamApi/test-api?agentidentity={agentIdentity}&agentUserId={agentUserId}&agentUsername={agentUsername}", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_AGENT_IDENTITY")); + Assert.True(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_USERNAME")); + Assert.False(capturedOptions?.AcquireTokenOptions?.ExtraParameters?.Keys.Contains("IDWEB_USER_ID")); + } + + + [Fact] + public async Task DownstreamApi_WithTenantOverride_PassesTenantIdToOptionsAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + string tenantOverride = Guid.NewGuid().ToString(); + + DownstreamApiOptions? capturedOptions = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((options, _, _, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + options.Scopes = ["user.read"]; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync($"/DownstreamApi/test-api?OptionsOverride.AcquireTokenOptions.Tenant={tenantOverride}", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(tenantOverride, capturedOptions?.AcquireTokenOptions?.Tenant); + } + + [Fact] + public async Task DownstreamApi_WithScopeOverride_PassesBothScopesToOptionsAsync() + { + // Arrange + var responseContent = "{\"result\": \"success\"}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json") + }; + + string tenantOverride = Guid.NewGuid().ToString(); + + DownstreamApiOptions? capturedOptions = null; + var mockDownstreamApi = new Mock(); + mockDownstreamApi + .Setup(x => x.CallApiAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((options, _, _, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + + services.Configure("test-api", options => + { + options.BaseUrl = "https://api.example.com"; + }); + + services.AddSingleton(mockDownstreamApi.Object); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync($"/DownstreamApi/test-api?OptionsOverride.Scopes=user.read&OptionsOverride.Scopes=user.write", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(["user.read", "user.write"], capturedOptions?.Scopes); + } + + [Fact] + public async Task DownstreamApi_WithNonExistentApiName_ReturnsBadRequestWithProblemDetailsAsync() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services); + }); + }).CreateClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token"); + + // Act + var response = await client.PostAsync("/DownstreamApi/non-existent-api", null); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + // Just verify it returns 400 - that's sufficient for this test + } + +} diff --git a/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs b/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs new file mode 100644 index 000000000..ec6a362c8 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Sidecar; +using Microsoft.Identity.Web.Sidecar.Endpoints; +using Xunit; + +namespace Sidecar.Tests; + +public class DownstreamApiOptionsMergeTests +{ + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithNullRight_ReturnsClonedLeft() + { + // Arrange + var left = new DownstreamApiOptions + { + Scopes = ["user.read"], + RelativePath = "/me" + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, null!); + + // Assert + Assert.NotSame(left, result); + Assert.Equal(left.Scopes, result.Scopes); + Assert.Equal(left.RelativePath, result.RelativePath); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithScopesOverride_OverridesScopes() + { + // Arrange + var left = new DownstreamApiOptions + { + Scopes = ["user.read"] + }; + var right = new DownstreamApiOptions + { + Scopes = ["mail.read", "calendars.read"] + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal(["mail.read", "calendars.read"], result.Scopes); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithEmptyScopes_DoesNotOverride() + { + // Arrange + var left = new DownstreamApiOptions + { + Scopes = ["user.read"] + }; + var right = new DownstreamApiOptions + { + Scopes = Array.Empty() + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal(["user.read"], result.Scopes); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithTenantOverride_OverridesTenant() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { Tenant = "original-tenant" } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { Tenant = "new-tenant" } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal("new-tenant", result.AcquireTokenOptions.Tenant); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithClaimsOverride_OverridesClaims() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { Claims = "original-claims" } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { Claims = "new-claims" } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal("new-claims", result.AcquireTokenOptions.Claims); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithAuthenticationOptionsNameOverride_OverridesAuthenticationOptionsName() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { AuthenticationOptionsName = "original-auth" } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { AuthenticationOptionsName = "new-auth" } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal("new-auth", result.AcquireTokenOptions.AuthenticationOptionsName); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithFmiPathOverride_OverridesFmiPath() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { FmiPath = "/original/path" } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { FmiPath = "/new/path" } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal("/new/path", result.AcquireTokenOptions.FmiPath); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithRelativePathOverride_OverridesRelativePath() + { + // Arrange + var left = new DownstreamApiOptions + { + RelativePath = "/original/path" + }; + var right = new DownstreamApiOptions + { + RelativePath = "/new/path" + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal("/new/path", result.RelativePath); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithForceRefreshOverride_OverridesForceRefresh() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { ForceRefresh = false } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions { ForceRefresh = true } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.True(result.AcquireTokenOptions.ForceRefresh); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithExtraParameters_MergesExtraParameters() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + { "param1", "value1" }, + { "param2", "value2" } + } + } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + { "param3", "value3" }, + { "param4", "value4" } + } + } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.NotNull(result.AcquireTokenOptions.ExtraParameters); + Assert.Equal(4, result.AcquireTokenOptions.ExtraParameters.Count); + Assert.Equal("value1", result.AcquireTokenOptions.ExtraParameters["param1"]); + Assert.Equal("value2", result.AcquireTokenOptions.ExtraParameters["param2"]); + Assert.Equal("value3", result.AcquireTokenOptions.ExtraParameters["param3"]); + Assert.Equal("value4", result.AcquireTokenOptions.ExtraParameters["param4"]); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithExtraParametersConflict_DoesNotOverwriteExisting() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + { "param1", "original-value" } + } + } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + { "param1", "new-value" }, + { "param2", "value2" } + } + } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.NotNull(result.AcquireTokenOptions.ExtraParameters); + Assert.Equal("original-value", result.AcquireTokenOptions.ExtraParameters["param1"]); + Assert.Equal("value2", result.AcquireTokenOptions.ExtraParameters["param2"]); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithRightExtraParametersButLeftNull_CreatesNewDictionary() + { + // Arrange + var left = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = null + } + }; + var right = new DownstreamApiOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + { "param1", "value1" } + } + } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.NotNull(result.AcquireTokenOptions.ExtraParameters); + Assert.Single(result.AcquireTokenOptions.ExtraParameters); + Assert.Equal("value1", result.AcquireTokenOptions.ExtraParameters["param1"]); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithComplexScenario_MergesAllOverrides() + { + // Arrange + var left = new DownstreamApiOptions + { + Scopes = ["user.read"], + RelativePath = "/original/path", + AcquireTokenOptions = new AcquireTokenOptions + { + Tenant = "original-tenant", + Claims = "original-claims", + ForceRefresh = false, + ExtraParameters = new Dictionary + { + { "original-param", "original-value" } + } + } + }; + + var right = new DownstreamApiOptions + { + Scopes = ["mail.read", "calendars.read"], + RelativePath = "/new/path", + AcquireTokenOptions = new AcquireTokenOptions + { + Tenant = "new-tenant", + AuthenticationOptionsName = "new-auth", + FmiPath = "/new/fmi", + ForceRefresh = true, + ExtraParameters = new Dictionary + { + { "new-param", "new-value" } + } + } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal(["mail.read", "calendars.read"], result.Scopes); + Assert.Equal("/new/path", result.RelativePath); + Assert.Equal("new-tenant", result.AcquireTokenOptions.Tenant); + Assert.Equal("original-claims", result.AcquireTokenOptions.Claims); // Not overridden + Assert.Equal("new-auth", result.AcquireTokenOptions.AuthenticationOptionsName); + Assert.Equal("/new/fmi", result.AcquireTokenOptions.FmiPath); + Assert.True(result.AcquireTokenOptions.ForceRefresh); + Assert.NotNull(result.AcquireTokenOptions.ExtraParameters); + Assert.Equal(2, result.AcquireTokenOptions.ExtraParameters.Count); + Assert.Equal("original-value", result.AcquireTokenOptions.ExtraParameters["original-param"]); + Assert.Equal("new-value", result.AcquireTokenOptions.ExtraParameters["new-param"]); + } + + [Fact] + public void MergeDownstreamApiOptionsOverrides_WithEmptyStringOverrides_DoesNotOverride() + { + // Arrange + var left = new DownstreamApiOptions + { + RelativePath = "/original/path", + AcquireTokenOptions = new AcquireTokenOptions + { + Tenant = "original-tenant", + Claims = "original-claims" + } + }; + var right = new DownstreamApiOptions + { + RelativePath = "", + AcquireTokenOptions = new AcquireTokenOptions + { + Tenant = "", + Claims = "" + } + }; + + // Act + var result = DownstreamApiOptionsMerger.MergeOptions(left, right); + + // Assert + Assert.Equal("/original/path", result.RelativePath); + Assert.Equal("original-tenant", result.AcquireTokenOptions.Tenant); + Assert.Equal("original-claims", result.AcquireTokenOptions.Claims); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/DownstreamApiUnauthenticatedEndpointTests.cs b/tests/E2E Tests/Sidecar.Tests/DownstreamApiUnauthenticatedEndpointTests.cs new file mode 100644 index 000000000..0cbc258de --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/DownstreamApiUnauthenticatedEndpointTests.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Sidecar.Models; +using Moq; +using Xunit; + +namespace Sidecar.Tests; + +public class DownstreamApiUnauthenticatedEndpointTests(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact] + public async Task DownstreamApiUnauthenticated_WithValidApiAndScopes_NoAuthHeader_ReturnsOkAsync() + { + // Arrange + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"success\":true}", Encoding.UTF8, "application/json") + }; + + ClaimsPrincipal? capturedPrincipal = null; + + var mockDownstream = new Mock(); + mockDownstream + .Setup(d => d.CallApiAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, principal, _, _) => + { + capturedPrincipal = principal; + }) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.Configure("test-api", o => + { + o.BaseUrl = "https://api.example.com"; + o.Scopes = ["api.read"]; + }); + services.AddSingleton(mockDownstream.Object); + }); + }).CreateClient(); + + // Act (no Authorization header) + var response = await client.PostAsync("/DownstreamApiUnauthenticated/test-api", new StringContent("", Encoding.UTF8, "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(200, result!.StatusCode); + Assert.False(capturedPrincipal?.Identity?.IsAuthenticated); + } + + [Fact] + public async Task DownstreamApiUnauthenticated_WithNonExistentApi_ReturnsBadRequestAsync() + { + // Arrange + var mockDownstream = new Mock(); + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(mockDownstream.Object); + // No configuration for api + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync("/DownstreamApiUnauthenticated/unknown-api", null); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + // For unknown we expect missing scopes message (consistent with authenticated tests) + Assert.Contains("No scopes found for the API 'unknown-api'", content, StringComparison.OrdinalIgnoreCase); + mockDownstream.Verify(d => d.CallApiAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownstreamApiUnauthenticated_WithConfiguredApiButNoScopes_ReturnsBadRequestAsync() + { + // Arrange + var mockDownstream = new Mock(); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.Configure("test-api", o => + { + o.BaseUrl = "https://api.example.com"; + // No scopes + }); + services.AddSingleton(mockDownstream.Object); + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync("/DownstreamApiUnauthenticated/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("No scopes found for the API 'test-api'", content, StringComparison.OrdinalIgnoreCase); + mockDownstream.Verify(d => d.CallApiAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownstreamApiUnauthenticated_WithChallengeUserException_ReturnsUnauthorizedAsync() + { + // Arrange + var msalException = new MsalUiRequiredException("AADSTS50076", "MFA required."); + var mockDownstream = new Mock(); + mockDownstream + .Setup(d => d.CallApiAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new MicrosoftIdentityWebChallengeUserException(msalException, ["api.read"])); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.Configure("test-api", o => + { + o.BaseUrl = "https://api.example.com"; + o.Scopes = ["api.read"]; + }); + services.AddSingleton(mockDownstream.Object); + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync("/DownstreamApiUnauthenticated/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("MFA required", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApiUnauthenticated_GenericException_ReturnsInternalServerErrorAsync() + { + // Arrange + var mockDownstream = new Mock(); + mockDownstream + .Setup(d => d.CallApiAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Failure")); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.Configure("test-api", o => + { + o.BaseUrl = "https://api.example.com"; + o.Scopes = ["api.read"]; + }); + services.AddSingleton(mockDownstream.Object); + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync("/DownstreamApiUnauthenticated/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("An unexpected error occurred", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApiUnauthenticated_WithSuccessfulCall_ReturnsResultAsync() + { + // Arrange + var payload = "{\"ok\":true}"; + var mockResponse = new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + var mockDownstream = new Mock(); + mockDownstream + .Setup(d => d.CallApiAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(mockResponse); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.Configure("test-api", o => + { + o.BaseUrl = "https://api.example.com"; + o.Scopes = ["api.read"]; + }); + services.AddSingleton(mockDownstream.Object); + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync("/DownstreamApiUnauthenticated/test-api", null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal((int)HttpStatusCode.Accepted, result!.StatusCode); + Assert.Equal(payload, result.Content); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/MockedEndToEndTests.cs b/tests/E2E Tests/Sidecar.Tests/MockedEndToEndTests.cs new file mode 100644 index 000000000..80dc086e8 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/MockedEndToEndTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Sidecar.Models; +using Xunit; + +namespace Sidecar.Tests; + +public class MockedEndToEndTests(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact] + public async Task MockedAuthorizationFlow_WithValidConfiguration_ReturnsAuthorizationHeaderAsync() + { + // Arrange + const string expectedAuthHeader = "Bearer token"; + const string apiName = "test-api"; + const string scope = "https://graph.microsoft.com/.default"; + + TestAuthorizationHeaderProvider mock = new() + { + Result = expectedAuthHeader + }; + + var client = _factory + .WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(mock); + services.Configure(apiName, options => + { + options.BaseUrl = "https://graph.microsoft.com"; + options.Scopes = new[] { scope }; + }); + }); + }) + .CreateClient(); + + // Add authentication header (would be validated in real scenario) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-test-token"); + + // Act + var response = await client.GetAsync($"/AuthorizationHeader/{apiName}"); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + // Expected in test environment without proper authentication setup + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + return; + } + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + Assert.NotNull(result); + Assert.Equal(expectedAuthHeader, result.AuthorizationHeader); + } +} + +class TestAuthorizationHeaderProvider : IAuthorizationHeaderProvider +{ + public string? Result { get; init; } + + public Task CreateAuthorizationHeaderAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? options = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) => Task.FromResult(Result ?? string.Empty); + + public Task CreateAuthorizationHeaderForAppAsync(string scopes, AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default) => Task.FromResult(Result ?? string.Empty); + + public Task CreateAuthorizationHeaderForUserAsync(IEnumerable scopes, AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, ClaimsPrincipal? claimsPrincipal = null, CancellationToken cancellationToken = default) => Task.FromResult(Result ?? string.Empty); +} diff --git a/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs b/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs new file mode 100644 index 000000000..09f447b00 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/ModelsTests.cs @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using Microsoft.Identity.Web.Sidecar.Models; +using Xunit; + +namespace Sidecar.Tests; + +public class ModelsTests +{ + [Fact] + public void AuthorizationHeaderResult_WithNullValue_HandlesCorrectly() + { + // Arrange & Act + var result = new AuthorizationHeaderResult(null!); + + // Assert + Assert.Null(result.AuthorizationHeader); + } + + [Fact] + public void AuthorizationHeaderResult_WithEmptyString_HandlesCorrectly() + { + // Arrange & Act + var result = new AuthorizationHeaderResult(string.Empty); + + // Assert + Assert.Equal(string.Empty, result.AuthorizationHeader); + } + + [Fact] + public void AuthorizationHeaderResult_ToString_ReturnsExpectedFormat() + { + // Arrange + var header = "Bearer test-token"; + var result = new AuthorizationHeaderResult(header); + + // Act + var stringResult = result.ToString(); + + // Assert + Assert.Contains(header, stringResult, StringComparison.Ordinal); + Assert.Contains("AuthorizationHeaderResult", stringResult, StringComparison.Ordinal); + } + + [Fact] + public void ValidateAuthorizationHeaderResult_Constructor_SetsAllProperties() + { + // Arrange + var protocol = "Bearer"; + var token = "token"; + var claims = JsonNode.Parse(""" + { + "sub": "user123", + "name": "Test User", + "scope": "user.read mail.read", + "aud": "api://test-api", + "iss": "https://login.microsoftonline.com/test-tenant/v2.0", + "exp": 1234567890 + } + """); + + // Act + var result = new ValidateAuthorizationHeaderResult(protocol, token, claims!); + + // Assert + Assert.Equal(protocol, result.Protocol); + Assert.Equal(token, result.Token); + Assert.Equal(claims, result.Claims); + } + + [Fact] + public void ValidateAuthorizationHeaderResult_WithNullClaims_HandlesCorrectly() + { + // Arrange & Act + var result = new ValidateAuthorizationHeaderResult("Bearer", "token", null!); + + // Assert + Assert.Equal("Bearer", result.Protocol); + Assert.Equal("token", result.Token); + Assert.Null(result.Claims); + } + + [Fact] + public void ValidateAuthorizationHeaderResult_Equality_WorksCorrectly() + { + // Arrange + var claims = JsonNode.Parse("""{"sub": "user123"}"""); + var result1 = new ValidateAuthorizationHeaderResult("Bearer", "token", claims!); + var result2 = new ValidateAuthorizationHeaderResult("Bearer", "token", claims!); + var result3 = new ValidateAuthorizationHeaderResult("Bearer", "different-token", claims!); + + // Act & Assert + Assert.Equal(result1, result2); + Assert.NotEqual(result1, result3); + } + + [Fact] + public void ValidateAuthorizationHeaderResult_ToString_ReturnsExpectedFormat() + { + // Arrange + var claims = JsonNode.Parse("""{"sub": "user123"}"""); + var result = new ValidateAuthorizationHeaderResult("Bearer", "test-token", claims!); + + // Act + var stringResult = result.ToString(); + + // Assert + Assert.Contains("Bearer", stringResult, StringComparison.Ordinal); + Assert.Contains("test-token", stringResult, StringComparison.Ordinal); + Assert.Contains("ValidateAuthorizationHeaderResult", stringResult, StringComparison.Ordinal); + } + + [Fact] + public void ValidateAuthorizationHeaderResult_WithComplexClaims_HandlesCorrectly() + { + // Arrange + var complexClaims = JsonNode.Parse(""" + { + "sub": "user123", + "name": "Test User", + "roles": ["admin", "user"], + "permissions": { + "read": true, + "write": false + }, + "nested": { + "deep": { + "value": "test" + } + } + } + """); + + // Act + var result = new ValidateAuthorizationHeaderResult("Bearer", "token", complexClaims!); + + // Assert + Assert.Equal("Bearer", result.Protocol); + Assert.Equal("token", result.Token); + Assert.NotNull(result.Claims); + + // Verify we can access nested properties + Assert.Equal("user123", result.Claims["sub"]?.GetValue()); + Assert.Equal("Test User", result.Claims["name"]?.GetValue()); + Assert.NotNull(result.Claims["roles"]?.AsArray()); + Assert.Equal(2, result.Claims["roles"]?.AsArray().Count); + Assert.Equal("test", result.Claims["nested"]?["deep"]?["value"]?.GetValue()); + } + + [Theory] + [InlineData("Bearer")] + [InlineData("Basic")] + [InlineData("Custom")] + [InlineData("")] + public void ValidateAuthorizationHeaderResult_WithDifferentProtocols_HandlesCorrectly(string protocol) + { + // Arrange + var claims = JsonNode.Parse("""{"sub": "user123"}"""); + + // Act + var result = new ValidateAuthorizationHeaderResult(protocol, "token", claims!); + + // Assert + Assert.Equal(protocol, result.Protocol); + } + + [Theory] + [InlineData("short")] + [InlineData("header.body.signature")] + [InlineData("")] + public void ValidateAuthorizationHeaderResult_WithDifferentTokenLengths_HandlesCorrectly(string token) + { + // Arrange + var claims = JsonNode.Parse("""{"sub": "user123"}"""); + + // Act + var result = new ValidateAuthorizationHeaderResult("Bearer", token, claims!); + + // Assert + Assert.Equal(token, result.Token); + } + + [Fact] + public void DownstreamApiResult_Constructor_SetsAllProperties() + { + // Arrange + var statusCode = 200; + var headers = new Dictionary> + { + { "Content-Type", ["application/json"] }, + { "Cache-Control", ["no-cache", "no-store"] } + }; + var content = "{\"result\": \"success\"}"; + + // Act + var result = new DownstreamApiResult(statusCode, headers, content); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + Assert.Equal(headers, result.Headers); + Assert.Equal(content, result.Content); + } + + [Fact] + public void DownstreamApiResult_WithNullContent_HandlesCorrectly() + { + // Arrange + var statusCode = 204; + var headers = new Dictionary>(); + + // Act + var result = new DownstreamApiResult(statusCode, headers, null); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + Assert.Equal(headers, result.Headers); + Assert.Null(result.Content); + } + + [Fact] + public void DownstreamApiResult_WithEmptyHeaders_HandlesCorrectly() + { + // Arrange + var statusCode = 200; + var headers = new Dictionary>(); + var content = "test content"; + + // Act + var result = new DownstreamApiResult(statusCode, headers, content); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + Assert.Empty(result.Headers); + Assert.Equal(content, result.Content); + } + + [Fact] + public void DownstreamApiResult_WithComplexHeaders_HandlesCorrectly() + { + // Arrange + var statusCode = 201; + var headers = new Dictionary> + { + { "Content-Type", ["application/json; charset=utf-8"] }, + { "Cache-Control", ["max-age=3600", "public"] }, + { "X-Custom-Header", ["value1", "value2", "value3"] }, + { "Location", ["https://api.example.com/resource/123"] } + }; + var content = "{\"id\": 123, \"name\": \"New Resource\"}"; + + // Act + var result = new DownstreamApiResult(statusCode, headers, content); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + Assert.Equal(4, result.Headers.Count); + Assert.Equal(["application/json; charset=utf-8"], result.Headers["Content-Type"]); + Assert.Equal(["max-age=3600", "public"], result.Headers["Cache-Control"]); + Assert.Equal(["value1", "value2", "value3"], result.Headers["X-Custom-Header"]); + Assert.Equal(["https://api.example.com/resource/123"], result.Headers["Location"]); + Assert.Equal(content, result.Content); + } + + [Theory] + [InlineData(200)] + [InlineData(201)] + [InlineData(204)] + [InlineData(400)] + [InlineData(401)] + [InlineData(404)] + [InlineData(500)] + public void DownstreamApiResult_WithDifferentStatusCodes_HandlesCorrectly(int statusCode) + { + // Arrange + var headers = new Dictionary>(); + var content = "test content"; + + // Act + var result = new DownstreamApiResult(statusCode, headers, content); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + } + + [Fact] + public void DownstreamApiResult_Equality_WorksCorrectly() + { + // Arrange + var headers = new Dictionary> + { + { "Content-Type", ["application/json"] } + }; + var content = "test content"; + + var result1 = new DownstreamApiResult(200, headers, content); + var result2 = new DownstreamApiResult(200, headers, content); + var result3 = new DownstreamApiResult(201, headers, content); + + // Act & Assert + Assert.Equal(result1, result2); + Assert.NotEqual(result1, result3); + } + + [Fact] + public void DownstreamApiResult_ToString_ReturnsExpectedFormat() + { + // Arrange + var headers = new Dictionary> + { + { "Content-Type", ["application/json"] } + }; + var result = new DownstreamApiResult(200, headers, "test content"); + + // Act + var stringResult = result.ToString(); + + // Assert + Assert.Contains("DownstreamApiResult", stringResult, StringComparison.Ordinal); + Assert.Contains("200", stringResult, StringComparison.Ordinal); + } + + [Fact] + public void DownstreamApiResult_WithLargeContent_HandlesCorrectly() + { + // Arrange + var statusCode = 200; + var headers = new Dictionary>(); + var largeContent = new string('x', 10000); // 10KB of 'x' characters + + // Act + var result = new DownstreamApiResult(statusCode, headers, largeContent); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + Assert.Equal(largeContent, result.Content); + Assert.Equal(10000, result.Content?.Length); + } + + [Fact] + public void DownstreamApiResult_WithSpecialCharactersInContent_HandlesCorrectly() + { + // Arrange + var statusCode = 200; + var headers = new Dictionary>(); + var specialContent = "Content with special chars: αβγδε, 中文, 🎉, \n\r\t, \"quotes\", 'apostrophes'"; + + // Act + var result = new DownstreamApiResult(statusCode, headers, specialContent); + + // Assert + Assert.Equal(statusCode, result.StatusCode); + Assert.Equal(specialContent, result.Content); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj b/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj new file mode 100644 index 000000000..6949d18fc --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/Sidecar.Tests.csproj @@ -0,0 +1,27 @@ + + + net9.0 + false + Sidecar.Tests + enable + enable + $(NoWarn); CS1591;RS0037 + ../../../build/MSAL.snk + + + + $(DefineConstants);FROM_GITHUB_ACTION + + + + + + + + + + + + + + diff --git a/tests/E2E Tests/Sidecar.Tests/SidecarApiFactory.cs b/tests/E2E Tests/Sidecar.Tests/SidecarApiFactory.cs new file mode 100644 index 000000000..dcf5632ce --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/SidecarApiFactory.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Sidecar; + +namespace Sidecar.Tests; + +public class SidecarApiFactory : WebApplicationFactory +{ + readonly Action _configureOptions; + + public SidecarApiFactory() : this(null) + { + } + + internal SidecarApiFactory(Action? configureOptions) + { + _configureOptions = configureOptions ?? (builder => + { + builder.AddInMemoryCollection(new Dictionary + { + { "AzureAd:Instance", "https://login.microsoftonline.com/" }, + { "AzureAd:TenantId", "31a58c3b-ae9c-4448-9e8f-e9e143e800df" }, + { "AzureAd:ClientId", "d15884b6-a447-4dd5-a5a5-a668c49f6300" }, + { "AzureAd:Audience", "d15884b6-a447-4dd5-a5a5-a668c49f6300" }, + { "AzureAd:ClientCredentials:0:SourceType", "StoreWithDistinguishedName" }, + { "AzureAd:ClientCredentials:0:CertificateStorePath", "LocalMachine/My" }, + { "AzureAd:ClientCredentials:0:CertificateDistinguishedName", "CN=LabAuth.MSIDLab.com" }, // Replace with the subject name of your certificate + { "DownstreamApis:MsGraph:BaseUrl", "https://graph.microsoft.com/v1.0/" }, + { "DownstreamApis:MsGraph:RelativePath", "/me" }, + { "DownstreamApis:MsGraph:Scopes:0", "User.Read" } + }); + }); + } + + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration(_configureOptions); + builder.ConfigureServices(services => + { + // Given we add the Json file after the initial configuration, and that + // downstream APIs are added to a IOptions, we need to re-add the downstream APIs + // with the new config + IConfiguration? configuration = services! + .First(s => s.ServiceType == typeof(IConfiguration)) + ?.ImplementationFactory + ?.Invoke(null!) as IConfiguration; + + services!.AddDownstreamApis(configuration!.GetSection("DownstreamApis")); + }); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/SidecarEndpointsE2ETests.cs b/tests/E2E Tests/Sidecar.Tests/SidecarEndpointsE2ETests.cs new file mode 100644 index 000000000..d06e93c86 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/SidecarEndpointsE2ETests.cs @@ -0,0 +1,202 @@ +// 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 Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Sidecar.Models; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; +using Xunit; + +namespace Sidecar.Tests; + +public class SidecarEndpointsE2ETests : IClassFixture +{ + private readonly SidecarApiFactory _factory; + + public SidecarEndpointsE2ETests(SidecarApiFactory factory) => _factory = factory; + + const string TenantId = "31a58c3b-ae9c-4448-9e8f-e9e143e800df"; // Replace with your tenant ID + const string AgentApplication = "d15884b6-a447-4dd5-a5a5-a668c49f6300"; // Replace with the actual agent application client ID + const string AgentIdentity = "d84da24a-2ea2-42b8-b5ab-8637ec208024"; // Replace with the actual agent identity + const string UserUpn = "aui1@msidlabtoint.onmicrosoft.com"; // Replace with the actual user upn. + string UserOid = "51c1aa1c-f6d0-4a92-936c-cadb27b717f2"; // Replace with the actual user OID. + + [Fact] + public async Task Validate_WhenBadTokenAsync() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "dummy-token"); + var response = await client.GetAsync("/Validate"); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("invalid_token", response.Headers.WwwAuthenticate.ToString(), StringComparison.CurrentCultureIgnoreCase); + } + + [Fact] + public async Task Validate_WhenGoodTokenAsync() + { + // Getting a token to call the API. + string authorizationHeader = await GetAuthorizationHeaderToCallTheSideCarAsync(); + + // Calling the API + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorizationHeader); + var response = await client.GetAsync("/Validate"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + + Assert.NotEmpty(content); + } + + [Fact] + public async Task GetAuthorizationHeaderForAgentUserIdentityAuthenticatedAsync() + { + // Getting a token to call the API. + string authorizationHeader = await GetAuthorizationHeaderToCallTheSideCarAsync(); + + // Calling the API + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorizationHeader); + var response = await client.GetAsync($"/AuthorizationHeader/MsGraph?agentidentity={AgentIdentity}&agentUsername={UserUpn}"); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DownstreamApiForAgentUserIdentityAuthenticated() + { + // Getting a token to call the API. + string authorizationHeader = await GetAuthorizationHeaderToCallTheSideCarAsync(); + + // Calling the API + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorizationHeader); + var response = await client.GetAsync($"/AuthorizationHeader/MsGraph?agentidentity={AgentIdentity}&agentUsername={UserUpn}"); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DownstreamApiForAgentUserIdentityAuthenticatedUsingOid() + { + // Getting a token to call the API. + string authorizationHeader = await GetAuthorizationHeaderToCallTheSideCarAsync(); + + // Calling the API + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorizationHeader); + var response = await client.GetAsync($"/AuthorizationHeader/MsGraph?agentidentity={AgentIdentity}&agentUserId={UserOid}"); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetAuthorizationHeaderForAgentUserIdentityUnauthenticatedAsync() + { + // Calling the API + var client = _factory.CreateClient(); + + var response = await client.GetAsync($"/AuthorizationHeaderUnauthenticated/MsGraph?agentidentity={AgentIdentity}&agentUsername={UserUpn}"); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetAuthorizationHeaderForAgentUserIdentityUnauthenticatedAsyncUseUpn() + { + // Calling the API + var client = _factory.CreateClient(); + + var response = await client.GetAsync($"/AuthorizationHeaderUnauthenticated/MsGraph?agentidentity={AgentIdentity}&agentUserId={UserOid}"); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetAuthorizationHeaderForAgentUserIdentityUnauthenticated_WithOptionsOverride() + { + var client = _factory.CreateClient(); + + var result = await client.GetAsync( + $"/AuthorizationHeaderUnauthenticated/AgentUserIdentityCallsGraph?AgentIdentity={AgentIdentity}&AgentUsername={UserUpn}&OptionsOverride.Tenant={TenantId}&OptionsOverride.Scopes=user.read"); + + Assert.True(result.IsSuccessStatusCode); + + var response = await result.Content.ReadFromJsonAsync(); + + Assert.NotNull(response?.AuthorizationHeader); + Assert.StartsWith("Bearer ey", response.AuthorizationHeader, StringComparison.Ordinal); + } + + [Fact] + public async Task GetDownstreamApiForAgentUserIdentityUnauthenticated() + { + // Calling the API + var client = _factory.CreateClient(); + var response = await client.PostAsync($"/DownstreamApi/MsGraph?agentidentity={AgentIdentity}&agentUsername={UserUpn}", null); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetDownstreamApiForAgentUserIdentityUnauthenticatedUseOid() + { + // Calling the API + var client = _factory.CreateClient(); + var response = await client.PostAsync($"/DownstreamApi/MsGraph?agentidentity={AgentIdentity}&agentUserId={UserOid}", null); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TestAgentIdentityConfiguration_InvalidTenant() + { + var client = _factory.CreateClient(); + + var result = await client.GetAsync( + $"/AuthorizationHeaderUnauthenticated/AgentUserIdentityCallsGraph?AgentIdentity={AgentIdentity}&AgentUsername={UserUpn}&OptionsOverride.AcquireTokenOptions.Tenant=invalid-tenant&OptionsOverride.Scopes=user.read"); + + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + } + + + private static async Task GetAuthorizationHeaderToCallTheSideCarAsync() + { + ServiceCollection services = new(); + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + services.AddSingleton(configuration); + configuration["Instance"] = "https://login.microsoftonline.com/"; + configuration["TenantId"] = "31a58c3b-ae9c-4448-9e8f-e9e143e800df"; + configuration["ClientId"] = "5cbcd9ff-c994-49ac-87e7-08a93a9c0794"; + configuration["SendX5C"] = "true"; + configuration["ClientCredentials:0:SourceType"] = "StoreWithDistinguishedName"; + configuration["ClientCredentials:0:CertificateStorePath"] = "LocalMachine/My"; + configuration["ClientCredentials:0:CertificateDistinguishedName"] = "CN=LabAuth.MSIDLab.com"; + + services.AddTokenAcquisition().AddHttpClient().AddInMemoryTokenCaches(); + services.Configure(configuration); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + + IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetRequiredService(); + string authorizationHeader = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("api://d15884b6-a447-4dd5-a5a5-a668c49f6300/.default", + new AuthorizationHeaderProviderOptions() + { + AcquireTokenOptions = new AcquireTokenOptions() + { + AuthenticationOptionsName = "" + } + }); + return authorizationHeader; + } +} + +#endif diff --git a/tests/E2E Tests/Sidecar.Tests/SidecarIntegrationTests.cs b/tests/E2E Tests/Sidecar.Tests/SidecarIntegrationTests.cs new file mode 100644 index 000000000..8d5bbdba6 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/SidecarIntegrationTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Xunit; + +namespace Sidecar.Tests; + +public class SidecarIntegrationTests(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact] + public async Task OpenApiEndpoint_IsAvailable_InDevelopmentAsync() + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/openapi/v1.json"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("openapi", content, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthorizationUnauthenticatedEndpoint_DownstreamApiAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + // The downstream API does not exist + var response = await client.GetAsync("/AuthorizationHeaderUnauthenticated/test?OptionsOverride.Scopes=scopes"); + + // Assert + string content = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("No account", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ValidateEndpoint_ExistsAndRequiresAuthAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Validate"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public void Services_AreRegisteredCorrectly() + { + // Arrange & Act + using var scope = _factory.Services.CreateScope(); + var services = scope.ServiceProvider; + + // Assert - Verify key services are registered + Assert.NotNull(services.GetService()); + + var tokenAcquisition = services.GetService(); + Assert.NotNull(tokenAcquisition); + } + + [Fact] + public async Task Application_HandlesInvalidRoutesAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/non-existent-endpoint"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Application_HandlesMethodNotAllowedAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Try POST on GET endpoint + var response = await client.PostAsync("/AuthorizationHeader/test", null); + + // Assert + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + + [Fact] + public async Task HealthCheck_IfConfigured_WorksAsync() + { + var client = _factory.CreateClient(); + + // Act - Just verify the healthz endpoint responds + var response = await client.GetAsync("/healthz"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public void Configuration_IsLoadedCorrectly() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var configuration = scope.ServiceProvider.GetRequiredService(); + + // Act & Assert + Assert.NotNull(configuration); + + // Verify configuration sections exist (they might be empty in test environment) + var azureAdSection = configuration.GetSection("AzureAd"); + Assert.NotNull(azureAdSection); + + var downstreamApiSection = configuration.GetSection("DownstreamApi"); + Assert.NotNull(downstreamApiSection); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/TestAuthenticationHandler.cs b/tests/E2E Tests/Sidecar.Tests/TestAuthenticationHandler.cs new file mode 100644 index 000000000..0420664e2 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/TestAuthenticationHandler.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sidecar.Tests; + +// Test Authentication Handler for mocking authentication +public class TestAuthenticationSchemeOptions : AuthenticationSchemeOptions { } + +public class TestAuthenticationHandler : AuthenticationHandler +{ + public static void AddAlwaysSucceedTestAuthentication(IServiceCollection services) + { + services.AddAuthentication("Test") + .AddScheme( + "Test", options => { }); + } + + public TestAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(ClaimTypes.Name, "TestUser"), + new Claim(ClaimTypes.NameIdentifier, "123"), + }; + + var identity = new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Test"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/tests/E2E Tests/Sidecar.Tests/ValidateEndpointTestsExtended.cs b/tests/E2E Tests/Sidecar.Tests/ValidateEndpointTestsExtended.cs new file mode 100644 index 000000000..cc37759de --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/ValidateEndpointTestsExtended.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Headers; +using Xunit; + +namespace Sidecar.Tests; + +public class ValidateEndpointTestsExtended(SidecarApiFactory factory) : IClassFixture +{ + private readonly SidecarApiFactory _factory = factory; + + [Fact] + public async Task Validate_WithoutAuthentication_ReturnsUnauthorizedAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Validate"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Validate_WithInvalidToken_ReturnsUnauthorizedAsync() + { + // Arrange + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); + + // Act + var response = await client.GetAsync("/Validate"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var authHeader = response.Headers.WwwAuthenticate.ToString(); + Assert.Contains("invalid_token", authHeader, StringComparison.CurrentCultureIgnoreCase); + } + + [Fact] + public async Task Validate_EndpointExists_AndRequiresAuthorizationAsync() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Validate"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Bearer", response.Headers.WwwAuthenticate.ToString(), StringComparison.OrdinalIgnoreCase); + } +}