Skip to content

Commit

Permalink
Hacked a SMART app launch POC
Browse files Browse the repository at this point in the history
This integrates with https://github.com/brianpos/smart-on-fhir.  It is a Windows app acting as a EHR to launch SMART Apps.  The only thing I had to do to get it to run on my Windows 11 box was to add the following binding redirect:

  <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <dependentAssembly>
      <assemblyIdentity name="System.Net.Http" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
      <bindingRedirect oldVersion="0.0.0.0-4.8.0.0" newVersion="4.8.0.0" />
    </dependentAssembly>
  </assemblyBinding>
</assemblyBinding>

Then in UdapEd I created a launchBP.razor page as the main launch.  In the "SmartSession from.well - known / smart - configuration" section click the CONTINUE TO AUTHORIZE button to get an access code.  Then click the  REQUEST TOKEN button to get an access token.  The token is placed in your user session so you can use it in the Patient Search Page.  But because the PatientSearch searches from the backend rather than WASM you can't use the https://fhir.test.localhost URL.  But you can use the one auto generated at startup based on port.  You will it in the bottom status area when the EHRApp starts up.
  • Loading branch information
JosephEShook committed Mar 26, 2024
1 parent 59b5fb3 commit 9a1726a
Show file tree
Hide file tree
Showing 19 changed files with 681 additions and 225 deletions.
20 changes: 16 additions & 4 deletions Client/Services/DiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@

using System.Net.Http.Json;
using Hl7.Fhir.Model;
using Hl7.Fhir.Rest;
using Hl7.Fhir.Serialization;
using Udap.Model;
using Udap.Smart.Model;
using UdapEd.Shared.Model;
using UdapEd.Shared.Model.Discovery;
using UdapEd.Shared.Services;
Expand All @@ -26,8 +29,8 @@ public DiscoveryService(HttpClient httpClient, ILogger<DiscoveryService> logger)
_httpClient = httpClient;
_logger = logger;
}

public async Task<MetadataVerificationModel?> GetMetadataVerificationModel(string metadataUrl, string? community, CancellationToken token)
public async Task<MetadataVerificationModel?> GetUdapMetadataVerificationModel(string metadataUrl, string? community, CancellationToken token)
{
try
{
Expand Down Expand Up @@ -68,15 +71,24 @@ public DiscoveryService(HttpClient httpClient, ILogger<DiscoveryService> logger)
{
try
{
return await _httpClient.GetFromJsonAsync<CapabilityStatement>(url, token);
//var response = await _httpClient.GetStringAsync($"Metadata/metadata?metadataUrl={url}");
var response = await _httpClient.GetStringAsync(url);
var statement = new FhirJsonParser().Parse<CapabilityStatement>(response);

return statement;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed GET /CapabilityStatement?");
_logger.LogError(ex, "Failed GET /Metadata/metadata");
return null;
}
}

public async Task<SmartMetadata?> GetSmartMetadata(string metadataUrl, CancellationToken token)
{
return await _httpClient.GetFromJsonAsync<SmartMetadata>(metadataUrl, token);
}

public async Task<CertificateStatusViewModel?> UploadAnchorCertificate(string certBytes)
{
var result = await _httpClient.PostAsJsonAsync("Metadata/UploadAnchorCertificate", certBytes);
Expand Down
10 changes: 5 additions & 5 deletions Client/UdapEd.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="BQuery" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="MudBlazor" Version="6.15.0" />
<PackageReference Include="MudBlazor" Version="6.19.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Shared\UdapEd.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Udap.Common" Version="0.3.*" />
<PackageReference Include="Udap.Common" Version="0.3.24" />
</ItemGroup>

<!-- From:: https://jonhilton.net/visual-studio-2022-blazor-css-intellisense/ -->
Expand Down
2 changes: 1 addition & 1 deletion Client/wwwroot/temp/MudBlazor.min.css

Large diffs are not rendered by default.

275 changes: 140 additions & 135 deletions Client/wwwroot/temp/MudBlazor.min.js

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions Server/Controllers/MetadataController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
// */
#endregion

using System;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Hl7.Fhir.Model;
using Hl7.Fhir.Rest;
using Hl7.Fhir.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -83,6 +87,34 @@ await _udapClient.ValidateResource(
return BadRequest("Missing anchor");
}

/// <summary>
/// This is ran from the WASM client instead via DirectoryService.cs
/// Leaving here for experimentation.
/// </summary>
/// <param name="metadataUrl"></param>
/// <returns></returns>
[HttpGet("metadata")]
public async Task<IActionResult> Get([FromQuery] string metadataUrl)
{
try
{
var client = new FhirClient(metadataUrl);
var result = await client.CapabilityStatementAsync();

if (result == null)
{
return NotFound();
}

return Ok(await result.ToJsonAsync());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed loading metadata from {metadataUrl}", metadataUrl);
return BadRequest();
}
}

// get metadata from .well-known/udap that is not validated and trust is not validated
[HttpGet("UnValidated")]
public async Task<IActionResult> GetUnValidated([FromQuery] string metadataUrl, [FromQuery] string community)
Expand Down
17 changes: 11 additions & 6 deletions Server/UdapEd.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Duende.IdentityServer" Version="7.0.1" />
<PackageReference Include="Duende.IdentityServer.Storage" Version="7.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.2" />
<PackageReference Include="Duende.IdentityServer" Version="7.0.3" />
<PackageReference Include="Duende.IdentityServer.Storage" Version="7.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.3" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.7.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
Expand All @@ -26,6 +26,7 @@
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.7.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.7.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.7.1" />
<PackageReference Include="Udap.Smart.Model" Version="0.3.24" />

</ItemGroup>

Expand All @@ -35,9 +36,9 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Udap.Client" Version="0.3.*" />
<PackageReference Include="Udap.Model" Version="0.3.*" />
<PackageReference Include="Udap.Common" Version="0.3.*" />
<PackageReference Include="Udap.Client" Version="0.3.24" />
<PackageReference Include="Udap.Model" Version="0.3.24" />
<PackageReference Include="Udap.Common" Version="0.3.24" />
</ItemGroup>

<ItemGroup>
Expand All @@ -55,4 +56,8 @@
</None>
</ItemGroup>

<ItemGroup>
<Folder Include="Services\" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion Shared/Model/Smart/SmartSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public SmartSession(string state)
State = state;
}

public StringValues ServiceUri { get; set; }
public string ServiceUri { get; set; }
public CapabilityStatement? CapabilityStatement { get; set; }
public string RedirectUri { get; set; }
public string? TokenUri { get; set; }
Expand Down
19 changes: 19 additions & 0 deletions Shared/Pages/Smart/Launch.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@page "/smart/launch"
@using Microsoft.IdentityModel.Tokens
@using UdapEd.Shared.Extensions
@using UdapEd.Shared.Model.Smart
@using UdapEd.Shared.Services
@using UdapEd.Shared.Services.Http
@using Microsoft.AspNetCore.WebUtilities




<MudExpansionPanels MultiExpansion="true">
<MudExpansionPanel Text="Metadata">
<pre>@Metadata</pre>
</MudExpansionPanel>
<MudExpansionPanel Text=".well-known/smart-configuration">
<pre>@SmartMetadata</pre>
</MudExpansionPanel>
</MudExpansionPanels>
106 changes: 55 additions & 51 deletions Shared/Pages/Launch.razor → Shared/Pages/Smart/Launch.razor.cs
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
@page "/launch"
@using Microsoft.AspNetCore.WebUtilities
@using UdapEd.Shared.Services
@using UdapEd.Shared.Services.Http
@using Microsoft.IdentityModel.Tokens
@using UdapEd.Shared.Extensions
@using UdapEd.Shared.Model.Smart

<MudCard Elevation="3" Style="margin-top: 10px">

<MudCardContent>
<MudGrid Justify="Justify.FlexStart">
<MudItem xs="12">
<MudIcon Color="Color.Primary" Size="Size.Large" Icon="@Icons.Material.Filled.FindInPage"></MudIcon>
<MudText Class="d-inline mud-typography-h5" >@GetMetadata()</MudText>
</MudItem>

</MudGrid>
</MudCardContent>
</MudCard>

@code {

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.IdentityModel.Tokens;
using UdapEd.Shared.Extensions;
using UdapEd.Shared.Model;
using UdapEd.Shared.Model.Smart;
using UdapEd.Shared.Services;
using UdapEd.Shared.Services.Http;
using UdapEd.Shared.Shared;

namespace UdapEd.Shared.Pages.Smart;

public partial class Launch
{
[CascadingParameter] public CascadingAppState AppState { get; set; } = null!;
[Inject] private NavigationManager NavManager { get; set; } = null!;
[Inject] private IDiscoveryService MetadataService { get; set; } = null!;
public string Metadata { get; set; }
public string SmartMetadata { get; set; }

/// <summary>
/// Method invoked when the component is ready to start, having received its
/// initial parameters from its parent in the render tree.
/// Override this method if you will perform an asynchronous operation and
/// want the component to refresh when that operation is completed.
/// </summary>
/// <returns>A <see cref="T:System.Threading.Tasks.Task" /> representing any asynchronous operation.</returns>
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();

[Inject] NavigationManager NavManager { get; set; } = null!;

[Inject] IDiscoveryService MetadataService { get; set; } = null!;
Metadata = await GetMetadata();
}

private async Task<string> GetMetadata()
{
Expand All @@ -35,6 +40,7 @@ private async Task<string> GetMetadata()
{
var queryParams = QueryHelpers.ParseQuery(uri.Query);
var iss = queryParams.GetValueOrDefault("iss");
var metadataUrl = $"{iss}/metadata";
var launch = queryParams.GetValueOrDefault("launch");

if (iss.IsNullOrEmpty())
Expand All @@ -47,20 +53,21 @@ private async Task<string> GetMetadata()
throw new MissingFieldException("Missing launch parameter");
}

var capabilityStatement = await MetadataService.GetCapabilityStatement(iss!, default);
if(capabilityStatement == null)
var capabilityStatement = await MetadataService.GetCapabilityStatement(metadataUrl, default);

if (capabilityStatement == null)
{
throw new MissingFieldException($"Missing CapabilityStatement at {iss}");
throw new MissingFieldException($"Missing CapabilityStatement at {metadataUrl}");
}

var oAuthUris = capabilityStatement.GetOAuthUris();

// if (oAuthUris.Authorization.IsNullOrEmpty() || oAuthUris.Token.IsNullOrEmpty())
// {
// var smartConfig = await MetadataService.GetSmartConfig(iss!, default);
// oAuthUris = smartConfig.GetOAuthUris();
// }
if (oAuthUris.Authorization.IsNullOrEmpty() || oAuthUris.Token.IsNullOrEmpty())
{
return "Missing OAuthUris";
// var smartConfig = await MetadataService.GetSmartMetadata(iss!, default);
// oAuthUris = smartConfig.GetOAuthUris();
}

var redirectUri = NavManager.BaseUri;
var state = Guid.NewGuid().ToString();
Expand All @@ -70,25 +77,25 @@ private async Task<string> GetMetadata()
//Pick your client
//first one for now.
var client = clients?.FirstOrDefault().Value;

var builder = new QueryStringBuilder(oAuthUris.Authorization)
.Add("response_type", "code")
.Add("client_id", client.ClientId)
.Add("scope", client.Scope)
.Add("client_id", client?.ClientId ?? string.Empty)
.Add("scope", client?.Scope ?? string.Empty)
.Add("redirect_uri", redirectUri)
.Add("aud", iss!)
.Add("launch", launch!)
.Add("state", state);

var session = new SmartSession(state)
{
ServiceUri = iss,
RedirectUri = redirectUri,
TokenUri = oAuthUris.Token,
CapabilityStatement = capabilityStatement,
AuthCodeUrlWithQueryString = builder.Build()
};
{
ServiceUri = iss,
RedirectUri = redirectUri,
TokenUri = oAuthUris.Token,
CapabilityStatement = capabilityStatement,
AuthCodeUrlWithQueryString = builder.Build()
};


var loginCallbackResult = new LoginCallBackResult
{
Expand All @@ -103,8 +110,5 @@ private async Task<string> GetMetadata()
}

return uri.Query.Replace("&", "&\r\n");



}
}
}
49 changes: 49 additions & 0 deletions Shared/Pages/Smart/LaunchBP.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
@page "/smart/launchBP"
@using UdapEd.Shared.Model.Smart
@using Microsoft.IdentityModel.Tokens


@if (string.IsNullOrEmpty(@AuthCode))
{
<MudExpansionPanels MultiExpansion="true">
<MudExpansionPanel Text="SmartSession from CapabiliSmartSessionMetadataent">
<pre>@SmartSessionCapabilityStatement</pre>
<MudExpansionPanels MultiExpansion="true">
<MudExpansionPanel Text="CapabilityStatement" class="mud-info">
<pre>@CapabilityStatement</pre>
</MudExpansionPanel>
</MudExpansionPanels>
</MudExpansionPanel>
<MudExpansionPanel Text="SmartSession from.well - known / smart - configuration">
<pre>@SmartSessionWellknownMetadata</pre>
<MudButton OnClick="SmartAuth">Continue to Authorize</MudButton>
<MudExpansionPanels MultiExpansion="true">
<MudExpansionPanel Text=".well - known / smart - configuration" class="mud-info">
<pre>@SmartMetadata</pre>
</MudExpansionPanel>
</MudExpansionPanels>
</MudExpansionPanel>
</MudExpansionPanels>
}
else
{
<MudCard Class="mud-typography-h5">
<MudCardHeader Class="bold">Smart Auth Code</MudCardHeader>
<MudCardContent>
<MudIcon Icon="@Icons.Material.Filled.Code" Class=""><MudSpacer /></MudIcon><MudText Class="d-inline">Code: @AuthCode</MudText>
<MudSpacer />
<MudIcon Icon="@Icons.Material.Filled.Key" Class=""><MudSpacer /></MudIcon><MudText Class="d-inline">State: @StateCode</MudText>
</MudCardContent>
<MudCardActions><MudButton OnClick="RequestToken">Request Token</MudButton> </MudCardActions>

@if (!Token.IsNullOrEmpty())
{
<MudCardHeader Class="bold">Access Token</MudCardHeader>
<MudCardContent>
<MudIcon Icon="@Icons.Material.Filled.Token"><MudSpacer/></MudIcon><MudText Class="d-inline">Token:</MudText>
<MudSpacer/>
<pre>@Token</pre>
</MudCardContent>
}
</MudCard>
}
Loading

0 comments on commit 9a1726a

Please sign in to comment.