Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update smart-configuration and metadata for SMART on FHIR #3697

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Microsoft.Health.Fhir.Api/Modules/SecurityModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Microsoft.Health.Fhir.Api.Configs;
using Microsoft.Health.Fhir.Api.Features.Bundle;
using Microsoft.Health.Fhir.Core.Configs;
using Microsoft.Health.Fhir.Core.Features.Conformance.Providers;
using Microsoft.Health.Fhir.Core.Features.Security;
using Microsoft.Health.Fhir.Core.Features.Security.Authorization;

Expand All @@ -34,6 +35,7 @@ public void Load(IServiceCollection services)
EnsureArg.IsNotNull(services, nameof(services));

services.AddSingleton<IBundleHttpContextAccessor, BundleHttpContextAccessor>();
services.AddSingleton<IWellKnownConfigurationProvider, WellKnownConfigurationProvider>();

// Set the token handler to not do auto inbound mapping. (e.g. "roles" -> "http://schemas.microsoft.com/ws/2008/06/identity/claims/role")
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public class SecurityConfiguration

public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();

public SmartAuthenticationConfiguration SmartAuthentication { get; set; } = new SmartAuthenticationConfiguration();

public virtual HashSet<string> PrincipalClaims { get; } = new HashSet<string>(StringComparer.Ordinal);

public AuthorizationConfiguration Authorization { get; set; } = new AuthorizationConfiguration();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.Configs
{
public class SmartAuthenticationConfiguration
{
public string Authority { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,69 +3,83 @@
// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
// -------------------------------------------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using EnsureThat;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Health.Fhir.Core.Configs;
using Microsoft.Health.Fhir.Core.Features.Conformance.Models;
using Microsoft.Health.Fhir.Core.Features.Conformance.Providers;
using Microsoft.Health.Fhir.Core.Features.Operations;
using Microsoft.Health.Fhir.Core.Messages.Get;
using Microsoft.Health.Fhir.Core.Models;

namespace Microsoft.Health.Fhir.Core.Features.Conformance
{
public class GetSmartConfigurationHandler : IRequestHandler<GetSmartConfigurationRequest, GetSmartConfigurationResponse>
{
private readonly SecurityConfiguration _securityConfiguration;
private readonly IWellKnownConfigurationProvider _configurationProvider;
private readonly ILogger<GetSmartConfigurationHandler> _logger;

public GetSmartConfigurationHandler(IOptions<SecurityConfiguration> securityConfigurationOptions)
public GetSmartConfigurationHandler(
IOptions<SecurityConfiguration> securityConfigurationOptions,
IWellKnownConfigurationProvider configurationProvider,
ILogger<GetSmartConfigurationHandler> logger)
{
EnsureArg.IsNotNull(securityConfigurationOptions?.Value, nameof(securityConfigurationOptions));

_securityConfiguration = securityConfigurationOptions.Value;
}

public Task<GetSmartConfigurationResponse> Handle(GetSmartConfigurationRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(Handle(request));
_securityConfiguration = EnsureArg.IsNotNull(securityConfigurationOptions?.Value, nameof(securityConfigurationOptions));
_configurationProvider = EnsureArg.IsNotNull(configurationProvider, nameof(configurationProvider));
_logger = EnsureArg.IsNotNull(logger, nameof(logger));
}

protected GetSmartConfigurationResponse Handle(GetSmartConfigurationRequest request)
public async Task<GetSmartConfigurationResponse> Handle(GetSmartConfigurationRequest request, CancellationToken cancellationToken)
{
EnsureArg.IsNotNull(request, nameof(request));

if (_securityConfiguration.Authorization.Enabled || _securityConfiguration.Authorization.EnableSmartWithoutAuth)
_logger.LogInformation("Starting processing of request to .well-known/smart-configuration endpoint.");

if (!_securityConfiguration.Authorization.Enabled && !_securityConfiguration.Authorization.EnableSmartWithoutAuth)
{
string baseEndpoint = _securityConfiguration.Authentication.Authority;
_logger.LogInformation("Security configuration is not enabled cannot process .well-known/smart-configuration request.");

try
{
Uri authorizationEndpoint = new Uri(baseEndpoint + "/authorize");
Uri tokenEndpoint = new Uri(baseEndpoint + "/token");
ICollection<string> capabilities = new List<string>
{
"sso-openid-connect",
"permission-offline",
"permission-patient",
"permission-user",
};
throw new OperationFailedException(
Core.Resources.SecurityConfigurationAuthorizationNotEnabled,
HttpStatusCode.BadRequest);
}

GetSmartConfigurationResponse smartConfiguration = await _configurationProvider.GetSmartConfigurationAsync(cancellationToken);

return new GetSmartConfigurationResponse(authorizationEndpoint, tokenEndpoint, capabilities);
if (smartConfiguration == null)
{
_logger.LogInformation("Identity provider does not support .well-known/smart-configuration using .well-known/openid-configuration instead.");

// If the SMART configuration failed, fall back to the OpenID configuration.
OpenIdConfigurationResponse openIdResponse = await _configurationProvider.GetOpenIdConfigurationAsync(cancellationToken);

if (openIdResponse?.AuthorizationEndpoint != null && openIdResponse?.TokenEndpoint != null)
{
smartConfiguration = new GetSmartConfigurationResponse(openIdResponse.AuthorizationEndpoint, openIdResponse.TokenEndpoint);
}
catch (Exception e) when (e is ArgumentNullException || e is UriFormatException)
}

if (smartConfiguration != null)
{
if (smartConfiguration.Capabilities.Count < 1)
{
throw new OperationFailedException(
string.Format(Core.Resources.InvalidSecurityConfigurationBaseEndpoint, nameof(SecurityConfiguration.Authentication.Authority)),
HttpStatusCode.BadRequest);
// Ensure the SMART configuration capabilities are populated with the minimum FHIR server capabilities.
smartConfiguration.Capabilities.Add("sso-openid-connect");
smartConfiguration.Capabilities.Add("permission-offline");
smartConfiguration.Capabilities.Add("permission-patient");
smartConfiguration.Capabilities.Add("permission-user");
}

return smartConfiguration;
}

throw new OperationFailedException(
Core.Resources.SecurityConfigurationAuthorizationNotEnabled,
string.Format(Core.Resources.InvalidSecurityConfigurationBaseEndpoint, nameof(SecurityConfiguration.Authentication.Authority)),
HttpStatusCode.BadRequest);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// -------------------------------------------------------------------------------------------------
// 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 Newtonsoft.Json;

namespace Microsoft.Health.Fhir.Core.Features.Conformance.Models
{
/// <summary>
/// This class represents the minimum required properties of the OpenID Connect discovery document used in the capability statement.
/// </summary>
public class OpenIdConfigurationResponse
{
public OpenIdConfigurationResponse(
Uri authorizationEndpoint,
Uri tokenEndpoint)
{
AuthorizationEndpoint = authorizationEndpoint;
TokenEndpoint = tokenEndpoint;
}

/// <summary>
/// REQUIRED, URL to the OAuth2 authorization endpoint.
/// </summary>
[JsonProperty("authorization_endpoint", DefaultValueHandling = DefaultValueHandling.Ignore)]
public Uri AuthorizationEndpoint { get; }

/// <summary>
/// REQUIRED, URL to the OAuth2 token endpoint.
/// </summary>
[JsonProperty("token_endpoint", DefaultValueHandling = DefaultValueHandling.Ignore)]
public Uri TokenEndpoint { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// -------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
// -------------------------------------------------------------------------------------------------

using System.Threading;
using System.Threading.Tasks;
using Microsoft.Health.Fhir.Core.Features.Conformance.Models;
using Microsoft.Health.Fhir.Core.Messages.Get;

namespace Microsoft.Health.Fhir.Core.Features.Conformance.Providers
{
public interface IWellKnownConfigurationProvider
{
bool IsSmartConfigured();

Task<GetSmartConfigurationResponse> GetSmartConfigurationAsync(CancellationToken cancellationToken);

Task<OpenIdConfigurationResponse> GetOpenIdConfigurationAsync(CancellationToken cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// -------------------------------------------------------------------------------------------------
// 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.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using EnsureThat;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Health.Fhir.Core.Configs;
using Microsoft.Health.Fhir.Core.Features.Conformance.Models;
using Microsoft.Health.Fhir.Core.Messages.Get;
using Newtonsoft.Json;

namespace Microsoft.Health.Fhir.Core.Features.Conformance.Providers
{
public class WellKnownConfigurationProvider : IWellKnownConfigurationProvider
{
private const string OpenIdConfigurationPath = ".well-known/openid-configuration";
private const string SmartConfigurationPath = ".well-known/smart-configuration";

private readonly SecurityConfiguration _securityConfiguration;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WellKnownConfigurationProvider> _logger;

public WellKnownConfigurationProvider(
IOptions<SecurityConfiguration> securityConfigurationOptions,
IHttpClientFactory httpClientFactory,
ILogger<WellKnownConfigurationProvider> logger)
{
_securityConfiguration = EnsureArg.IsNotNull(securityConfigurationOptions?.Value, nameof(securityConfigurationOptions));
_httpClientFactory = EnsureArg.IsNotNull(httpClientFactory, nameof(httpClientFactory));
_logger = EnsureArg.IsNotNull(logger, nameof(logger));
}

public bool IsSmartConfigured()
{
return !string.IsNullOrWhiteSpace(_securityConfiguration?.SmartAuthentication?.Authority);
}

public Task<OpenIdConfigurationResponse> GetOpenIdConfigurationAsync(CancellationToken cancellationToken)
{
return GetConfigurationAsync<OpenIdConfigurationResponse>(OpenIdConfigurationPath, cancellationToken);
}

public Task<GetSmartConfigurationResponse> GetSmartConfigurationAsync(CancellationToken cancellationToken)
{
Uri configurationUrl = GetConfigurationUrl(SmartConfigurationPath);

if (configurationUrl != null && string.Equals("localhost", configurationUrl.Host, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("The FHIR service is running locally with no Authority set, {URL} is not available.", SmartConfigurationPath);
return Task.FromResult<GetSmartConfigurationResponse>(null);
}

return GetConfigurationAsync<GetSmartConfigurationResponse>(SmartConfigurationPath, cancellationToken);
}

private async Task<TConfiguration> GetConfigurationAsync<TConfiguration>(string configurationPath, CancellationToken cancellationToken)
where TConfiguration : class
{
Uri configurationUrl = GetConfigurationUrl(configurationPath);

if (configurationUrl != null)
{
_logger.LogInformation("Fetching configuration using well-known endpoint: {ConfigurationUrl}.", configurationUrl.AbsoluteUri);

using HttpClient client = _httpClientFactory.CreateClient();
using var smartConfigurationRequest = new HttpRequestMessage(HttpMethod.Get, configurationUrl);
HttpResponseMessage response = await client.SendAsync(smartConfigurationRequest, cancellationToken);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you leave Auth/Audience blank in appsettings, and simply run the OSS server, and then hit the .well-known/smart-configuration endpoint, you get an endless loop as the server makes a request against it's own endpoint :).

I also wonder if we can't rely on an Oauth compliant IDP and then provide the "smart" compliance ourselves? I am concerned that there may be few full "smart" compliant IDPs and we may limit the usability of this if we can't augment Oauth to provide a well-known/smart-configuration endpoint from the FHIR service, which in turn relies on the .well-known/openid-configuration of the IDP.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you leave Auth/Audience blank in appsettings, and simply run the OSS server, and then hit the .well-known/smart-configuration endpoint, you get an endless loop as the server makes a request against it's own endpoint :).

I guess I'm not sure why this would happen. If you leave Authority blank, does the SecurityConfiguration.Authentication.Authority value get populated some other way? The GetConfigurationUrl method below expects that if both values are null or empty, null should be returned here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Security is true, but no Auth or Audience is specified we use an in memory Identity Provider which loads users, roles and permitted data actions from role.json. This is for testing purposes. It will publish an authorize and token endpoint.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also wonder if we can't rely on an Oauth compliant IDP and then provide the "smart" compliance ourselves? I am concerned that there may be few full "smart" compliant IDPs and we may limit the usability of this if we can't augment Oauth to provide a well-known/smart-configuration endpoint from the FHIR service, which in turn relies on the .well-known/openid-configuration of the IDP.

I have a solution for this and we can talk about where this solution belongs (either this repository or a separate one). The main issue with SMART is that the response from .well-known/smart-configuration contains capabilities representing both the FHIR server and the IDP. The capabilities of the FHIR server and the IDP need to be combined by the implementor. This probably means that there are settings that need to be configured depending on the IDP.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Security is true, but no Auth or Audience is specified we use an in memory Identity Provider which loads users, roles and permitted data actions from role.json. This is for testing purposes. It will publish an authorize and token endpoint.

I added a condition to address this specific case. Let me know what you think.


if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Successfully fetched the configuration from {ConfigurationUrl}.", configurationUrl.AbsoluteUri);

try
{
string smartConfigurationJson = await response.Content.ReadAsStringAsync(cancellationToken: cancellationToken);
return JsonConvert.DeserializeObject<TConfiguration>(smartConfigurationJson);
}
catch (JsonSerializationException ex)
{
_logger.LogError(ex, "An error parsing the configuration response from \"{ConfigurationUrl}\" occurred.", configurationUrl.AbsoluteUri);
return null;
}
}

_logger.LogWarning("The configuration request to \"{ConfigurationUrl}\" returned a {StatusCode} status code.", configurationUrl.AbsoluteUri, response.StatusCode);
return null;
}

_logger.LogInformation("Authority is not valid cannot process {ConfigurationPath} request.", configurationPath);

return null;
}

private Uri GetConfigurationUrl(string configurationPath)
{
// Prefer the SmartAuthentication authority, but default to Authentication authority.
string authority = _securityConfiguration?.SmartAuthentication?.Authority ?? _securityConfiguration?.Authentication?.Authority;

if (!string.IsNullOrWhiteSpace(authority))
{
if (!authority.EndsWith('/'))
{
authority += "/";
}

try
{
return new Uri($"{authority}{configurationPath}");
}
catch (UriFormatException)
{
return null;
}
}

return null;
}
}
}
Loading
Loading