diff --git a/build/package.yml b/build/package.yml index 60cf9e55ef..1019d03bea 100644 --- a/build/package.yml +++ b/build/package.yml @@ -56,6 +56,13 @@ steps: pathToPublish: './testauthenvironment.json' artifactName: 'deploy' artifactType: 'container' + + - task: PublishBuildArtifacts@1 + displayName: 'publish corstestconfiguration.json' + inputs: + pathToPublish: './corstestconfiguration.json' + artifactName: 'deploy' + artifactType: 'container' - task: PublishBuildArtifacts@1 displayName: 'publish release directory' diff --git a/corstestconfiguration.json b/corstestconfiguration.json new file mode 100644 index 0000000000..2178281767 --- /dev/null +++ b/corstestconfiguration.json @@ -0,0 +1,10 @@ +{ + "FhirServer": { + "Cors": { + "Origins": ["https://localhost:6001"], + "Methods": ["*"], + "Headers": ["*"], + "MaxAge": 1440 + } + } +} diff --git a/samples/templates/default-azuredeploy.json b/samples/templates/default-azuredeploy.json index 9beeede830..9fa01caffc 100644 --- a/samples/templates/default-azuredeploy.json +++ b/samples/templates/default-azuredeploy.json @@ -40,13 +40,6 @@ ], "defaultValue": "S1" }, - "allowedOrigins": { - "type": "array", - "defaultValue": [], - "metadata": { - "description": "CORS Allowed Origins" - } - }, "securityAuthenticationAuthority": { "type": "string", "defaultValue": "", @@ -200,12 +193,7 @@ }, "properties": { "clientAffinityEnabled": false, - "serverFarmId": "[resourceId(variables('appServicePlanResourceGroup'), 'Microsoft.Web/serverfarms/', variables('appServicePlanName'))]", - "siteConfig": { - "cors": { - "allowedOrigins": "[parameters('allowedOrigins')]" - } - } + "serverFarmId": "[resourceId(variables('appServicePlanResourceGroup'), 'Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" }, "dependsOn": [ "nestedTemplate" @@ -219,7 +207,7 @@ "[concat('Microsoft.Web/Sites/', variables('serviceName'))]", "[if(variables('deployAppInsights'),concat('Microsoft.Insights/components/', variables('appInsightsName')),resourceId('Microsoft.KeyVault/vaults', variables('serviceName')))]" ], - "properties" : "[if(variables('deployAppInsights'), union(variables('combinedFhirServerConfigProperties'), json(concat('{\"APPINSIGHTS_INSTRUMENTATIONKEY\": \"', reference(concat('Microsoft.Insights/components/', variables('appInsightsName'))).InstrumentationKey, '\"}'))), variables('combinedFhirServerConfigProperties'))]" + "properties": "[if(variables('deployAppInsights'), union(variables('combinedFhirServerConfigProperties'), json(concat('{\"APPINSIGHTS_INSTRUMENTATIONKEY\": \"', reference(concat('Microsoft.Insights/components/', variables('appInsightsName'))).InstrumentationKey, '\"}'))), variables('combinedFhirServerConfigProperties'))]" }, { "apiVersion": "2015-08-01", diff --git a/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Cors/CorsModuleTests.cs b/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Cors/CorsModuleTests.cs new file mode 100644 index 0000000000..c124a68de7 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Cors/CorsModuleTests.cs @@ -0,0 +1,118 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Health.Fhir.Api.Configs; +using Microsoft.Health.Fhir.Api.Modules; +using Microsoft.Health.Fhir.Core.Configs; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Fhir.Api.UnitTests.Features.Cors +{ + public class CorsModuleTests + { + private readonly CorsModule _corsModule; + private readonly CorsConfiguration _corsConfiguration = Substitute.For(); + private readonly IServiceCollection _servicesCollection = Substitute.For(); + + public CorsModuleTests() + { + var fhirServerConfiguration = Substitute.For(); + + fhirServerConfiguration.Cors.Returns(_corsConfiguration); + + _corsModule = new CorsModule(fhirServerConfiguration); + } + + [Fact] + public void GivenACorsConfiguration_WhenNoValuesSet_PolicyHasOnlyDefaults() + { + _corsModule.Load(_servicesCollection); + + CorsPolicy corsPolicy = _corsModule.DefaultCorsPolicy; + Assert.Empty(corsPolicy.Origins); + Assert.Empty(corsPolicy.Headers); + Assert.Empty(corsPolicy.Methods); + Assert.False(corsPolicy.SupportsCredentials); + Assert.Null(corsPolicy.PreflightMaxAge); + } + + [Fact] + public void GivenACorsConfiguration_WhenAllOriginsSet_PolicyHasAllowAnyOrigin() + { + _corsConfiguration.Origins.Add("*"); + _corsModule.Load(_servicesCollection); + + Assert.True(_corsModule.DefaultCorsPolicy.AllowAnyOrigin); + } + + [Fact] + public void GivenACorsConfiguration_WhenAllMethodsSet_PolicyHasAllowAnyMethod() + { + _corsConfiguration.Methods.Add("*"); + _corsModule.Load(_servicesCollection); + + Assert.True(_corsModule.DefaultCorsPolicy.AllowAnyMethod); + } + + [Fact] + public void GivenACorsConfiguration_WhenAllHeadersSet_PolicyHasAllowAnyHeader() + { + _corsConfiguration.Headers.Add("*"); + _corsModule.Load(_servicesCollection); + + Assert.True(_corsModule.DefaultCorsPolicy.AllowAnyHeader); + } + + [Fact] + public void GivenACorsConfiguration_WhenAllowCredentials_PolicyHasSupportsCredentials() + { + _corsConfiguration.AllowCredentials = true; + _corsModule.Load(_servicesCollection); + + Assert.True(_corsModule.DefaultCorsPolicy.SupportsCredentials); + } + + [Fact] + public void GivenACorsConfiguration_WhenMaxAgeSet_PolicyHasMaxAge() + { + _corsConfiguration.MaxAge = 100; + _corsModule.Load(_servicesCollection); + + Assert.Equal(TimeSpan.FromSeconds(100), _corsModule.DefaultCorsPolicy.PreflightMaxAge); + } + + [Fact] + public void GivenACorsConfiguration_WhenMultipleValuesSet_PolicyHasSpecifiedValues() + { + _corsConfiguration.Origins.Add("https://example.com"); + _corsConfiguration.Origins.Add("https://contoso"); + + _corsConfiguration.Methods.Add("PATCH"); + _corsConfiguration.Methods.Add("DELETE"); + + _corsConfiguration.Headers.Add("authorization"); + _corsConfiguration.Headers.Add("content-type"); + + _corsModule.Load(_servicesCollection); + + Assert.Equal(2, _corsModule.DefaultCorsPolicy.Origins.Count); + Assert.Equal(2, _corsModule.DefaultCorsPolicy.Methods.Count); + Assert.Equal(2, _corsModule.DefaultCorsPolicy.Headers.Count); + + Assert.Contains("https://example.com", _corsModule.DefaultCorsPolicy.Origins); + Assert.Contains("https://contoso", _corsModule.DefaultCorsPolicy.Origins); + + Assert.Contains("PATCH", _corsModule.DefaultCorsPolicy.Methods); + Assert.Contains("DELETE", _corsModule.DefaultCorsPolicy.Methods); + + Assert.Contains("authorization", _corsModule.DefaultCorsPolicy.Headers); + Assert.Contains("content-type", _corsModule.DefaultCorsPolicy.Headers); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Api/Configs/FhirServerConfiguration.cs b/src/Microsoft.Health.Fhir.Api/Configs/FhirServerConfiguration.cs index 4f8e75676e..0aa5b7cc8a 100644 --- a/src/Microsoft.Health.Fhir.Api/Configs/FhirServerConfiguration.cs +++ b/src/Microsoft.Health.Fhir.Api/Configs/FhirServerConfiguration.cs @@ -14,5 +14,7 @@ public class FhirServerConfiguration public ConformanceConfiguration Conformance { get; } = new ConformanceConfiguration(); public SecurityConfiguration Security { get; } = new SecurityConfiguration(); + + public virtual CorsConfiguration Cors { get; } = new CorsConfiguration(); } } diff --git a/src/Microsoft.Health.Fhir.Api/Modules/CorsModule.cs b/src/Microsoft.Health.Fhir.Api/Modules/CorsModule.cs new file mode 100644 index 0000000000..3428eac5f3 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Api/Modules/CorsModule.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Linq; +using EnsureThat; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Health.Extensions.DependencyInjection; +using Microsoft.Health.Fhir.Api.Configs; +using Microsoft.Health.Fhir.Core.Configs; +using Microsoft.Health.Fhir.Core.Features.Cors; + +namespace Microsoft.Health.Fhir.Api.Modules +{ + public class CorsModule : IStartupModule + { + private readonly CorsConfiguration _corsConfiguration; + + public CorsModule(FhirServerConfiguration fhirServerConfiguration) + { + EnsureArg.IsNotNull(fhirServerConfiguration, nameof(fhirServerConfiguration)); + _corsConfiguration = fhirServerConfiguration.Cors; + } + + internal CorsPolicy DefaultCorsPolicy { get; private set; } + + public void Load(IServiceCollection services) + { + EnsureArg.IsNotNull(services, nameof(services)); + + var corsPolicyBuilder = new CorsPolicyBuilder() + .WithOrigins(_corsConfiguration.Origins.ToArray()) + .WithHeaders(_corsConfiguration.Headers.ToArray()) + .WithMethods(_corsConfiguration.Methods.ToArray()); + + if (_corsConfiguration.MaxAge != null) + { + corsPolicyBuilder.SetPreflightMaxAge(TimeSpan.FromSeconds(_corsConfiguration.MaxAge.Value)); + } + + if (_corsConfiguration.AllowCredentials) + { + corsPolicyBuilder.AllowCredentials(); + } + + DefaultCorsPolicy = corsPolicyBuilder.Build(); + + services.AddCors(options => + { + options.AddPolicy( + Constants.DefaultCorsPolicy, + DefaultCorsPolicy); + }); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Api/Registration/FhirServerServiceCollectionExtensions.cs b/src/Microsoft.Health.Fhir.Api/Registration/FhirServerServiceCollectionExtensions.cs index 73bcd1544b..ede4f348c7 100644 --- a/src/Microsoft.Health.Fhir.Api/Registration/FhirServerServiceCollectionExtensions.cs +++ b/src/Microsoft.Health.Fhir.Api/Registration/FhirServerServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ using Microsoft.Health.Fhir.Api.Features.Context; using Microsoft.Health.Fhir.Api.Features.Exceptions; using Microsoft.Health.Fhir.Api.Features.Headers; +using Microsoft.Health.Fhir.Core.Features.Cors; using Microsoft.Health.Fhir.Core.Registration; namespace Microsoft.Extensions.DependencyInjection @@ -52,6 +53,7 @@ public static IFhirServerBuilder AddFhirServer( services.AddSingleton(Options.Options.Create(fhirServerConfiguration.Security)); services.AddSingleton(Options.Options.Create(fhirServerConfiguration.Conformance)); services.AddSingleton(Options.Options.Create(fhirServerConfiguration.Features)); + services.AddSingleton(Options.Options.Create(fhirServerConfiguration.Cors)); services.AddTransient(); services.RegisterAssemblyModules(Assembly.GetExecutingAssembly(), fhirServerConfiguration); @@ -86,6 +88,8 @@ public Action Configure(Action next) // This middleware will add delegates to the OnStarting method of httpContext.Response for setting headers. app.UseBaseHeaders(); + app.UseCors(Constants.DefaultCorsPolicy); + // This middleware should be registered at the beginning since it generates correlation id among other things, // which will be used in other middlewares. app.UseFhirRequestContext(); diff --git a/src/Microsoft.Health.Fhir.Core/Configs/CorsConfiguration.cs b/src/Microsoft.Health.Fhir.Core/Configs/CorsConfiguration.cs new file mode 100644 index 0000000000..a52e735cca --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Configs/CorsConfiguration.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; + +namespace Microsoft.Health.Fhir.Core.Configs +{ + public class CorsConfiguration + { + public IList Origins { get; } = new List(); + + public IList Headers { get; } = new List(); + + public IList Methods { get; } = new List(); + + public int? MaxAge { get; set; } + + public bool AllowCredentials { get; set; } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Cors/Constants.cs b/src/Microsoft.Health.Fhir.Core/Features/Cors/Constants.cs new file mode 100644 index 0000000000..eda1f212f3 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Cors/Constants.cs @@ -0,0 +1,12 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Fhir.Core.Features.Cors +{ + public static class Constants + { + public const string DefaultCorsPolicy = "DefaultCorsPolicy"; + } +} diff --git a/src/Microsoft.Health.Fhir.Web/appsettings.json b/src/Microsoft.Health.Fhir.Web/appsettings.json index 708ff57525..02c7e6f5c2 100644 --- a/src/Microsoft.Health.Fhir.Web/appsettings.json +++ b/src/Microsoft.Health.Fhir.Web/appsettings.json @@ -24,6 +24,13 @@ "CosmosDb": { "CollectionId": "fhir", "InitialCollectionThroughput": null + }, + "Cors": { + "Origins": [], + "Methods": [], + "Headers": [], + "MaxAge": null, + "AllowCredentials": false } }, "ControlPlane": { diff --git a/test/Microsoft.Health.Fhir.Tests.E2E/Microsoft.Health.Fhir.Tests.E2E.csproj b/test/Microsoft.Health.Fhir.Tests.E2E/Microsoft.Health.Fhir.Tests.E2E.csproj index 795cfbb246..e3db169fbe 100644 --- a/test/Microsoft.Health.Fhir.Tests.E2E/Microsoft.Health.Fhir.Tests.E2E.csproj +++ b/test/Microsoft.Health.Fhir.Tests.E2E/Microsoft.Health.Fhir.Tests.E2E.csproj @@ -18,6 +18,9 @@ PreserveNewest + + PreserveNewest + diff --git a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/CorsTests.cs b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/CorsTests.cs new file mode 100644 index 0000000000..bc9501d3ea --- /dev/null +++ b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/CorsTests.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Fhir.Tests.E2E.Common; +using Microsoft.Health.Fhir.Web; +using Microsoft.Net.Http.Headers; +using Xunit; +using HttpMethod = System.Net.Http.HttpMethod; + +namespace Microsoft.Health.Fhir.Tests.E2E.Rest +{ + public class CorsTests : IClassFixture> + { + private readonly FhirClient _client; + + public CorsTests(HttpIntegrationTestFixture fixture) + { + _client = fixture.FhirClient; + } + + [Fact] + [Trait(Traits.Priority, Priority.One)] + public async Task WhenGettingOptions_GivenAppropriateHeaders_TheServerShouldReturnTheAppropriateCorsHeaders() + { + var message = new HttpRequestMessage + { + Method = HttpMethod.Options, + }; + + message.Headers.Add(HeaderNames.Origin, "https://localhost:6001"); + message.Headers.Add(HeaderNames.AccessControlRequestMethod, "PUT"); + message.Headers.Add(HeaderNames.AccessControlRequestHeaders, "authorization"); + message.Headers.Add(HeaderNames.AccessControlRequestHeaders, "content-type"); + message.RequestUri = new Uri(_client.HttpClient.BaseAddress, "/patient"); + + HttpResponseMessage response = await _client.HttpClient.SendAsync(message); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Contains("https://localhost:6001", response.Headers.GetValues(HeaderNames.AccessControlAllowOrigin)); + + Assert.Contains("PUT", response.Headers.GetValues(HeaderNames.AccessControlAllowMethods)); + + Assert.Contains("authorization,content-type", response.Headers.GetValues(HeaderNames.AccessControlAllowHeaders)); + + Assert.Equal("1440", response.Headers.GetValues(HeaderNames.AccessControlMaxAge).First()); + } + } +} diff --git a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/HttpIntegrationTestFixture.cs b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/HttpIntegrationTestFixture.cs index 710bdba686..b464959cb5 100644 --- a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/HttpIntegrationTestFixture.cs +++ b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/HttpIntegrationTestFixture.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Health.Fhir.Web; @@ -93,10 +94,15 @@ public HttpClient CreateHttpClient() private void StartInMemoryServer(string targetProjectParentDirectory) { var contentRoot = GetProjectPath(targetProjectParentDirectory, typeof(TStartup)); + var corsPath = Path.GetFullPath("corstestconfiguration.json"); var builder = WebHost.CreateDefaultBuilder() .UseContentRoot(contentRoot) - .ConfigureAppConfiguration(configurationBuilder => configurationBuilder.AddDevelopmentAuthEnvironment("testauthenvironment.json")) + .ConfigureAppConfiguration(configurationBuilder => + { + configurationBuilder.AddDevelopmentAuthEnvironment("testauthenvironment.json"); + configurationBuilder.AddJsonFile(corsPath); + }) .UseStartup(typeof(TStartup)) .ConfigureServices(serviceCollection => {