Skip to content

Commit 262fbb4

Browse files
authored
Fixes .well-known/smart-configuration, and update test app (#5407)
Fix .well-known/smart-configuration endpoint, and update SMART test app Refs AB#184176
1 parent 4f67062 commit 262fbb4

File tree

18 files changed

+2354
-366
lines changed

18 files changed

+2354
-366
lines changed

samples/apps/SmartLauncher/Models/SmartLauncherConfig.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
namespace Microsoft.Health.Internal.SmartLauncher.Models
77
{
8+
/// <summary>
9+
/// Configuration that is safe to expose to the browser via /config.
10+
/// Secret values (ClientSecret, CertificatePath, etc.) are read directly
11+
/// from IConfiguration in the token proxy and are never serialized here.
12+
/// </summary>
813
internal class SmartLauncherConfig
914
{
1015
#pragma warning disable CA1056 // URI-like properties should not be strings
@@ -14,5 +19,9 @@ internal class SmartLauncherConfig
1419

1520
#pragma warning restore CA1056 // URI-like properties should not be strings
1621
public string ClientId { get; set; }
22+
23+
public string ClientType { get; set; } = "public";
24+
25+
public string Scopes { get; set; } = string.Empty;
1726
}
1827
}
Lines changed: 165 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,177 @@
1-
// -------------------------------------------------------------------------------------------------
1+
// -------------------------------------------------------------------------------------------------
22
// Copyright (c) Microsoft Corporation. All rights reserved.
33
// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
44
// -------------------------------------------------------------------------------------------------
55

6-
using Microsoft.AspNetCore;
7-
using Microsoft.AspNetCore.Hosting;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.IdentityModel.Tokens.Jwt;
9+
using System.Net.Http;
10+
using System.Security.Cryptography.X509Certificates;
11+
using System.Text;
12+
using System.Threading.Tasks;
13+
using Microsoft.AspNetCore.Builder;
14+
using Microsoft.AspNetCore.Http;
15+
using Microsoft.Extensions.Configuration;
16+
using Microsoft.Extensions.DependencyInjection;
17+
using Microsoft.Health.Internal.SmartLauncher.Models;
18+
using Microsoft.IdentityModel.Tokens;
19+
20+
var builder = WebApplication.CreateBuilder(args);
21+
builder.Services.AddHttpClient();
22+
23+
var app = builder.Build();
24+
string cachedTokenEndpoint = null;
25+
26+
app.UseDefaultFiles();
27+
app.UseStaticFiles();
28+
29+
// GET /config — serves public configuration (no secrets)
30+
app.MapGet("/config", (IConfiguration configuration) =>
31+
{
32+
var config = new SmartLauncherConfig();
33+
configuration.Bind(config);
34+
return Results.Ok(config);
35+
});
36+
37+
// POST /token-proxy — proxies token exchange for confidential clients
38+
app.MapPost("/token-proxy", async (HttpRequest request, IConfiguration configuration, IHttpClientFactory httpClientFactory) =>
39+
{
40+
var form = await request.ReadFormAsync();
41+
var grantType = form["grant_type"].ToString();
42+
var code = form["code"].ToString();
43+
var redirectUri = form["redirect_uri"].ToString();
44+
var codeVerifier = form["code_verifier"].ToString();
45+
var clientId = configuration["ClientId"] ?? string.Empty;
46+
var clientType = configuration["ClientType"] ?? "public";
47+
48+
// Derive the token endpoint server-side from the configured FHIR server's
49+
// SMART configuration to prevent SSRF via a client-supplied URL.
50+
var fhirServerUrl = configuration["FhirServerUrl"];
51+
if (string.IsNullOrEmpty(fhirServerUrl))
52+
{
53+
return Results.BadRequest(new { error = "FhirServerUrl is not configured on the server." });
54+
}
55+
56+
string tokenEndpoint;
57+
try
58+
{
59+
if (string.IsNullOrEmpty(cachedTokenEndpoint))
60+
{
61+
using var discoveryClient = httpClientFactory.CreateClient();
62+
var smartConfigUrl = fhirServerUrl.TrimEnd('/') + "/.well-known/smart-configuration";
63+
var smartResponse = await discoveryClient.GetAsync(new Uri(smartConfigUrl));
64+
smartResponse.EnsureSuccessStatusCode();
65+
var smartJson = await smartResponse.Content.ReadAsStringAsync();
66+
var smartConfig = System.Text.Json.JsonDocument.Parse(smartJson);
67+
cachedTokenEndpoint = smartConfig.RootElement.GetProperty("token_endpoint").GetString()
68+
?? throw new InvalidOperationException("token_endpoint not found in SMART configuration.");
69+
}
70+
71+
tokenEndpoint = cachedTokenEndpoint;
72+
}
73+
catch (HttpRequestException ex)
74+
{
75+
return Results.Problem($"Failed to discover token endpoint from {fhirServerUrl}: {ex.Message}", statusCode: 502);
76+
}
77+
catch (System.Text.Json.JsonException ex)
78+
{
79+
return Results.Problem($"Failed to parse SMART configuration from {fhirServerUrl}: {ex.Message}", statusCode: 502);
80+
}
81+
catch (InvalidOperationException ex)
82+
{
83+
return Results.Problem($"Invalid SMART configuration from {fhirServerUrl}: {ex.Message}", statusCode: 502);
84+
}
85+
86+
var tokenRequestParams = new Dictionary<string, string>
87+
{
88+
["grant_type"] = grantType,
89+
["code"] = code,
90+
["redirect_uri"] = redirectUri,
91+
["client_id"] = clientId,
92+
};
93+
94+
if (!string.IsNullOrEmpty(codeVerifier))
95+
{
96+
tokenRequestParams["code_verifier"] = codeVerifier;
97+
}
98+
99+
using var httpClient = httpClientFactory.CreateClient();
100+
using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
101+
{
102+
Content = new FormUrlEncodedContent(tokenRequestParams),
103+
};
104+
105+
if (clientType.Equals("confidential-symmetric", StringComparison.OrdinalIgnoreCase))
106+
{
107+
var clientSecret = configuration["ClientSecret"] ?? string.Empty;
108+
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
109+
tokenRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
110+
}
111+
else if (clientType.Equals("confidential-asymmetric", StringComparison.OrdinalIgnoreCase))
112+
{
113+
var assertion = GenerateClientAssertion(clientId, tokenEndpoint, configuration);
114+
tokenRequestParams["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
115+
tokenRequestParams["client_assertion"] = assertion;
116+
tokenRequest.Content = new FormUrlEncodedContent(tokenRequestParams);
117+
}
118+
119+
var response = await httpClient.SendAsync(tokenRequest);
120+
var content = await response.Content.ReadAsStringAsync();
121+
122+
return Results.Content(content, "application/json", statusCode: (int)response.StatusCode);
123+
});
124+
125+
app.Run();
8126

9-
namespace Microsoft.Health.Internal.SmartLauncher
127+
static string GenerateClientAssertion(string clientId, string tokenEndpoint, IConfiguration configuration)
10128
{
11-
internal static class Program
129+
X509Certificate2 cert = LoadCertificate(configuration);
130+
131+
var securityKey = new X509SecurityKey(cert);
132+
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
133+
134+
var now = DateTime.UtcNow;
135+
var token = new JwtSecurityToken(
136+
issuer: clientId,
137+
audience: tokenEndpoint,
138+
claims: new[]
139+
{
140+
new System.Security.Claims.Claim("sub", clientId),
141+
new System.Security.Claims.Claim("jti", Guid.NewGuid().ToString()),
142+
},
143+
notBefore: now,
144+
expires: now.AddMinutes(5),
145+
signingCredentials: signingCredentials);
146+
147+
return new JwtSecurityTokenHandler().WriteToken(token);
148+
}
149+
150+
static X509Certificate2 LoadCertificate(IConfiguration configuration)
151+
{
152+
var certPath = configuration["CertificatePath"];
153+
var certPassword = configuration["CertificatePassword"];
154+
var certThumbprint = configuration["CertificateThumbprint"];
155+
156+
if (!string.IsNullOrEmpty(certPath))
157+
{
158+
#pragma warning disable SYSLIB0057 // X509Certificate2 constructor is obsolete in .NET 9+
159+
return new X509Certificate2(certPath, certPassword);
160+
#pragma warning restore SYSLIB0057
161+
}
162+
163+
if (!string.IsNullOrEmpty(certThumbprint))
12164
{
13-
public static void Main(string[] args)
165+
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
166+
store.Open(OpenFlags.ReadOnly);
167+
var certs = store.Certificates.Find(X509FindType.FindByThumbprint, certThumbprint, validOnly: false);
168+
if (certs.Count == 0)
14169
{
15-
CreateWebHostBuilder(args).Build().Run();
170+
throw new InvalidOperationException($"Certificate with thumbprint '{certThumbprint}' not found in CurrentUser\\My store.");
16171
}
17172

18-
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
19-
WebHost.CreateDefaultBuilder(args)
20-
.UseStartup<Startup>();
173+
return certs[0];
21174
}
175+
176+
throw new InvalidOperationException("No certificate configured. Set either CertificatePath or CertificateThumbprint in appsettings.json.");
22177
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# SMART on FHIR v2 Sample Launcher
2+
3+
A sample web app that demonstrates SMART on FHIR v2 standalone launch with OAuth2/PKCE authentication against a FHIR server. It supports multiple client types and two OAuth flow patterns.
4+
5+
## Quick Start
6+
7+
1. Configure `appsettings.json` with your FHIR server URL and client ID.
8+
2. Run the app:
9+
```bash
10+
dotnet run --project samples/apps/SmartLauncher
11+
```
12+
3. Open `https://localhost:<port>` in a browser.
13+
4. Select your auth mode, scopes, and click **Launch App**.
14+
15+
## Configuration
16+
17+
All settings are in `appsettings.json`:
18+
19+
| Key | Required | Description |
20+
|-----|----------|-------------|
21+
| `FhirServerUrl` | Yes | Base URL of the FHIR server |
22+
| `ClientId` | Yes | OAuth2 client/application ID |
23+
| `ClientType` | No | `public` (default), `confidential-symmetric`, or `confidential-asymmetric` |
24+
| `Scopes` | No | Default scopes (space-separated). Defaults to common SMART v2 scopes if empty |
25+
| `DefaultSmartAppUrl` | No | Launch target path. Defaults to `/sampleapp/launch.html` |
26+
| `ClientSecret` | No | Client secret (for `confidential-symmetric` only) |
27+
| `CertificatePath` | No | Path to .pfx certificate file (for `confidential-asymmetric` token proxy) |
28+
| `CertificatePassword` | No | Password for the certificate file |
29+
| `CertificateThumbprint` | No | Alternative to `CertificatePath`: thumbprint to load from the Windows cert store |
30+
31+
## Client Types
32+
33+
### Public
34+
35+
No credentials required. The browser exchanges the authorization code directly with the token endpoint using PKCE. Suitable for SPAs and environments where secrets cannot be stored securely.
36+
37+
### Confidential-Symmetric (Client Secret)
38+
39+
Uses a shared secret (`ClientSecret`) for authentication. In the **token proxy** flow, the server adds an HTTP Basic `Authorization` header when exchanging the code. In the **fhirclient** flow, the secret is passed to the fhirclient library in the browser (less secure).
40+
41+
### Confidential-Asymmetric (Private Key JWT)
42+
43+
Uses a signed JWT assertion (`private_key_jwt`) to prove client identity. No secret is transmitted.
44+
45+
- **Token proxy flow (recommended for Entra ID):** The server signs the JWT using an X.509 certificate configured via `CertificatePath` or `CertificateThumbprint`. Uses RS256, which is compatible with Entra ID.
46+
- **fhirclient flow:** The browser signs the JWT using a private JWK (RS384 or ES384 per SMART v2 spec). The launcher UI includes tools to generate key pairs and export certificates.
47+
48+
> **Entra ID note:** Entra ID requires RS256 for client assertions, not the RS384/ES384 algorithms in the SMART v2 spec. Use the token proxy flow for Entra ID compatibility.
49+
50+
## OAuth Flows
51+
52+
### Standard Flow (fhirclient library)
53+
54+
Uses the [fhirclient.js](https://github.com/smart-on-fhir/client-js) library to handle the full OAuth2 flow in the browser.
55+
56+
1. `launch.html` loads fhirclient.js and calls `FHIR.oauth2.authorize()`.
57+
2. The user authenticates and grants scopes at the authorization server.
58+
3. The browser is redirected back to `index.html` with an authorization code.
59+
4. fhirclient.js exchanges the code for tokens (adding credentials for confidential clients).
60+
5. The sample app displays the token response and fetches the patient resource.
61+
62+
**When to use:** Standard SMART v2 servers, public clients, or when you don't need Entra ID asymmetric auth.
63+
64+
### Token Proxy Flow
65+
66+
Uses a server-side proxy (`/token-proxy`) to exchange the authorization code. Secrets and certificates never leave the server.
67+
68+
1. `launch.html` manually generates a PKCE pair and redirects to the authorization endpoint.
69+
2. The user authenticates and grants scopes.
70+
3. The browser is redirected back to `index.html` with an authorization code.
71+
4. The browser sends the code to the `/token-proxy` server endpoint.
72+
5. The server discovers the token endpoint from the FHIR server's `/.well-known/smart-configuration`, attaches client credentials, and forwards the token request.
73+
6. The token response is returned to the browser.
74+
75+
**When to use:** Confidential clients (especially asymmetric with Entra ID), or any scenario where credentials should not be exposed to the browser.
76+
77+
## Launcher UI
78+
79+
The launcher page (`/`) provides:
80+
81+
- **Auth mode selection** — public, symmetric, or asymmetric, with conditional credential fields.
82+
- **OAuth flow selection** — standard (fhirclient) or token proxy.
83+
- **Scope picker** — checkboxes for common SMART v2 scopes with an editable text field.
84+
- **Key generation tools** (asymmetric only) — generate ES384/RS384 key pairs in the browser, export public JWKS for app registration, and export X.509 certificates for Entra ID.
85+
86+
## Server Endpoints
87+
88+
| Endpoint | Method | Description |
89+
|----------|--------|-------------|
90+
| `/config` | GET | Returns public configuration (FHIR server URL, client ID, client type, scopes). No secrets. |
91+
| `/token-proxy` | POST | Proxies the authorization code token exchange. Discovers the token endpoint server-side to prevent SSRF. |
92+
93+
## Automated Setup
94+
95+
`Setup-SmartOnFhirEntraClient.ps1` is a PowerShell script that automates Entra ID app registration, certificate generation, and configuration.

0 commit comments

Comments
 (0)