- 開啟 Visual Studio 2026
- 選擇「建立新專案」
- 在 [建立新專案] 視窗中,在右方清單內,找到並選擇「Blazor Web 應用程式」 項目

- 然後點擊右下方「下一步」按鈕
- 此時將會看到 [設定新的專案] 對話窗
- 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "SmartEHRLaunch1"

- 然後點擊右下方「下一步」按鈕
- 接著會看到 [其他資訊] 對話窗
- 在這個對話窗內,確認使用底下的選項
- 然後點擊右下方「建立」按鈕
- 現在,已經完成了這個 Blazor 專案的建立

- 滑鼠右擊 [SmartEHRLaunch1] 專案節點
- 點選彈出功能表的 [管理 NuGet 套件] 項
- 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
- Hl7.Fhir.R4
在這個 Smart On FHIR 官方網頁上的 App Launch: Launch and Authorization 網頁內容,說明了要開發出一個 Smart On FHIR App,必須要有一個「啟動點 (Launch Endpoint)」頁面,這個頁面會負責處理來自 EHR 系統的啟動請求,並且引導使用者進行授權流程。接下來就是要能夠處理授權處理,最後就是要使用授權碼來取得存取權杖 (Access Token),然後使用這個存取權杖來存取 FHIR 資源。
由於要將這些全部作法寫在一篇文章內,會顯得過於冗長,因此這篇文章先示範如何建立一個「啟動點 (Launch Endpoint)」頁面,讓這個頁面能夠接收來自 EHR 系統的啟動請求,並且將這些請求參數顯示在頁面上。
在這裡將會說明採用 [EHR Launch] 模式,透過沙盒來啟動這個 App,對於採用 [Standalone Launch] 模式的 App,則會透過其他的文章來說明這樣的系統開如何開發出來。
- 在剛剛建立的 [SmartEHRLaunch1] 專案中,滑鼠右擊 [Components] > [Pages] 資料夾
- 點選彈出功能表的 [加入] > [Razor 元件] 項目
- 在 [新增項目] 對話窗的最下方之 [名稱] 欄位中,輸入頁面名稱為 [Launch]
- 然後點擊右下方的 [新增] 按鈕

- 接下要來建立這個頁面的 Code Behind 的程式碼檔案
- 在剛剛建立的 [SmartEHRLaunch1] 專案中,滑鼠右擊 [Components] > [Pages] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 底下操作將會是在 [顯示精簡檢視] 模式下操作
- 在 [新增項目] 對話窗的文字輸入盒中,輸入檔案名稱為 [Launch.razor.cs]
- 然後點擊右下方的 [新增] 按鈕

- 在剛剛建立的 [Launch.razor.cs] 程式碼檔案中,輸入底下的程式碼內容
namespace SmartEHRLaunch1.Components.Pages;
public partial class Launch
{
}- 這樣正式完成這個頁面的 Code Behind 的程式碼檔案建立
- 滑鼠右擊 [SmartEHRLaunch1] 專案節點
- 點選彈出功能表的 [加入] > [新增資料夾] 項目
- 建立這個 [Helpers] 資料夾
- 滑鼠右擊 [Helpers] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 在文字輸入盒內輸入檔案名稱為 [MagicObjectHelper.cs]
- 然後點擊右下方的 [新增] 按鈕
- 使用底下程式碼內容來取代剛剛建立的 [MagicObjectHelper.cs] 程式碼檔案內容
namespace SmartEHRLaunch1.Helpers;
public class MagicObjectHelper
{
public const string SmartAppSettingKey = "SmartAppSetting";
}- 滑鼠右擊 [SmartEHRLaunch1] 專案節點
- 點選彈出功能表的 [加入] > [新增資料夾] 項目
- 建立這個 [Models] 資料夾
- 滑鼠右擊 [SmartEHRLaunch1] 專案節點
- 點選彈出功能表的 [加入] > [新增資料夾] 項目
- 建立這個 [Servicers] 資料夾
- 滑鼠右擊 [Models] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 在文字輸入盒內輸入檔案名稱為 [SmartAppSettingModel.cs]
- 然後點擊右下方的 [新增] 按鈕

- 使用底下程式碼內容來取代剛剛建立的 [SmartAppSettingModel.cs] 程式碼檔案內容
namespace SmartEHRLaunch1.Models;
/// <summary>
/// Smart On FHIR 設定模型,用於儲存與 FHIR 伺服器互動所需的各項設定值。
/// </summary>
public class SmartAppSettingModel
{
/// <summary>
/// FHIR 伺服器的基底網址。
/// </summary>
public string FhirServerUrl { get; set; }
/// <summary>
/// Gets or sets the unique identifier for the client application.
/// </summary>
public string ClientId { get; set; }
/// <summary>
/// 授權完成後 Smart App 的重新導向網址。
/// </summary>
public string RedirectUrl { get; set; }
/// <summary>
/// 從授權伺服器取得的授權碼 (Authorization Code)。
/// </summary>
public string AuthCode { get; set; }
/// <summary>
/// 用於驗證請求完整性的客戶端狀態字串。
/// </summary>
public string ClientState { get; set; } = "local_state";
/// <summary>
/// 交換 Access Token 的授權伺服器 Token Endpoint 位址。
/// </summary>
public string TokenUrl { get; set; }
/// <summary>
/// 用於與外部提供者啟動授權流程的 URL。
/// </summary>
public string AuthorizeUrl { get; set; }
/// <summary>
/// 可用來是否透過 EHR Launch 來啟動授權流程的參數。
/// </summary>
public string Iss { get; set; }
/// <summary>
/// 啟動 EHR Launch 時,由 EHR 系統傳遞給 Smart App 的啟動參數,用於在授權流程中關聯當次啟動工作階段。
/// </summary>
public string Launch { get; set; }
/// <summary>
/// 儲存狀態鍵值
/// </summary>
public string State { get; set; }
}- 這個資料模型,將會用於儲存與 Smart On FHIR 互動所需的各項設定值
- 滑鼠右擊 [Servicers] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 在文字輸入盒內輸入檔案名稱為 [SmartAppSettingService.cs]
- 然後點擊右下方的 [新增] 按鈕
- 使用底下程式碼內容來取代剛剛建立的 [SmartAppSettingService.cs] 程式碼檔案內容
using SmartEHRLaunch1.Models;
namespace SmartEHRLaunch1.Servicers;
public class SmartAppSettingService
{
public SmartAppSettingModel Data = new SmartAppSettingModel();
public SmartAppSettingService()
{
}
public void UpdateSetting(SmartAppSettingModel model)
{
Data.FhirServerUrl = model.FhirServerUrl;
Data.ClientId = model.ClientId;
Data.RedirectUrl = model.RedirectUrl;
Data.AuthCode = model.AuthCode;
Data.ClientState = model.ClientState;
Data.TokenUrl = model.TokenUrl;
Data.AuthorizeUrl = model.AuthorizeUrl;
Data.Iss = model.Iss;
Data.Launch = model.Launch;
Data.State = model.State;
}
}- 這個服務類別,將會用於提供 Smart On FHIR 設定模型的相關服務
- 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到
var app = builder.Build();程式碼 - 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
#region 客製化註冊服務
builder.Services.AddScoped<SmartAppSettingService>();
#endregion- 滑鼠右擊 [Models] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 在文字輸入盒內輸入檔案名稱為 [SettingModel.cs]
- 然後點擊右下方的 [新增] 按鈕
- 使用底下程式碼內容來取代剛剛建立的 [SettingModel.cs] 程式碼檔案內容
namespace SmartEHRLaunch1.Models;
public class SettingModel
{
public string FhirServerUrl { get; set; }
public string RedirectUrl { get; set; }
public string ClientId { get; set; }
}- 這個資料模型,將會用於儲存 appsetting.json 的系統設定值
- 滑鼠右擊 [Servicers] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 在文字輸入盒內輸入檔案名稱為 [SettingService.cs]
- 然後點擊右下方的 [新增] 按鈕
- 使用底下程式碼內容來取代剛剛建立的 [SettingService.cs] 程式碼檔案內容
using Microsoft.Extensions.Options;
using SmartEHRLaunch1.Models;
namespace SmartEHRLaunch1.Servicers;
public class SettingService
{
private readonly SettingModel settingModel;
public SettingService(IOptions<SettingModel> options)
{
settingModel = options.Value;
}
public SettingModel GetValue()
{
return settingModel;
}
}- 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到
#region 客製化註冊服務程式碼 - 在這行程式碼後,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
builder.Services.AddScoped<SettingService>();- 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到
#region 客製化註冊服務程式碼 - 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
#region 加入設定強型別注入宣告
builder.Services.Configure<SettingModel>(builder.Configuration
.GetSection(MagicObjectHelper.SmartAppSettingKey));
#endregion- 開啟專案根目錄下的 [appsettings.json] 設定檔案
- 找到
"AllowedHosts": "*"這個內容 - 在這行程式碼前,加入底下的 json 內容
"SmartAppSetting": {
"FhirServerUrl": "https://thas.mohw.gov.tw/v/r4/fhir",
"RedirectUrl": "https://localhost:7191/ExchangeToken",
"ClientId": "smart-app"
}- 這裡的服務,將會用於儲存 OAuth 狀態資訊,因為,這些資訊將會在進行 OAuth 認證過程中,因為頁面切換而造成遺失
- 滑鼠右擊 [Servicers] 資料夾
- 點選彈出功能表的 [加入] > [新增項目] 項目
- 在文字輸入盒內輸入檔案名稱為 [OAuthStateStoreService.cs]
- 然後點擊右下方的 [新增] 按鈕
- 使用底下程式碼內容來取代剛剛建立的 [OAuthStateStoreService.cs] 程式碼檔案內容
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
namespace SmartEHRLaunch1.Servicers;
public class OAuthStateStoreService
{
private const string KeyPrefix = "oauth:state:";
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
private readonly IDistributedCache _cache;
public OAuthStateStoreService(IDistributedCache cache) => _cache = cache;
public async Task<string> SaveAsync<T>(string stateId, T state, TimeSpan ttl, CancellationToken ct = default)
{
var key = KeyPrefix + stateId;
var json = JsonSerializer.Serialize(state, JsonOpts);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
};
await _cache.SetStringAsync(key, json, options, ct);
return stateId;
}
public async Task<T?> LoadAsync<T>(string stateId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(stateId)) return default;
var key = KeyPrefix + stateId;
var json = await _cache.GetStringAsync(key, ct);
if (string.IsNullOrWhiteSpace(json)) return default;
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
public Task RemoveAsync(string stateId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(stateId)) return Task.CompletedTask;
return _cache.RemoveAsync(KeyPrefix + stateId, ct);
}
}- 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到
#region 客製化註冊服務程式碼 - 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
builder.Services.AddScoped<OAuthStateStoreService>();- 由於,在這個系統中,將會採用分散式快取 (Distributed Cache) 來儲存 OAuth 狀態資訊,因此,需要進行分散式快取服務的註冊
- 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到
#region 客製化註冊服務程式碼 - 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
// 提供 IDistributedCache 的記憶體實作
builder.Services.AddDistributedMemoryCache();- 在 [Components] > [Pages] 資料夾下
- 找到並且開啟 [Launch.razor] 程式碼檔案
- 使用底下程式碼內容來取代剛剛建立的 [Launch.razor] 程式碼檔案內容
@page "/launch"
<h3>請稍後,正在初始化中</h3>
<div>
<div>@IssMessage</div>
<div>@LaunchMessage</div>
<div>@authUrlMessage</div>
</div>
@code {
// https://localhost:7108/launch
[SupplyParameterFromQuery(Name = "iss")]
public string? Iss { get; set; }
[SupplyParameterFromQuery(Name = "launch")]
public string? LaunchCode { get; set; }
}- 在 [Components] > [Pages] 資料夾下
- 找到並且開啟 [Launch.razor.cs] 程式碼檔案
- 使用底下程式碼內容來取代剛剛建立的 [Launch.razor.cs] 程式碼檔案內容
using Hl7.Fhir.Model;
using Microsoft.AspNetCore.Components;
using SmartEHRLaunch1.Models;
using SmartEHRLaunch1.Servicers;
namespace SmartEHRLaunch1.Components.Pages;
public partial class Launch
{
[Inject]
public NavigationManager NavigationManager { get; init; }
[Inject]
public SmartAppSettingService SmartAppSettingService { get; init; }
[Inject]
public OAuthStateStoreService OAuthStateStoreService { get; init; }
string IssMessage = string.Empty;
string LaunchMessage = string.Empty;
string authUrlMessage = string.Empty;
protected override async System.Threading.Tasks.Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
KeepLaunchIss();
var bar = await GetMetadataAsync();
var authUrl = await GetAuthorizeUrlAsync();
authUrlMessage = $"重新導向到授權伺服器:{authUrl}";
StateHasChanged();
await System.Threading.Tasks.Task.Delay(5000);
NavigationManager.NavigateTo(authUrl);
}
}
public void KeepLaunchIss()
{
if (string.IsNullOrEmpty(Iss) || string.IsNullOrEmpty(LaunchCode))
{
SmartAppSettingService.Data.Iss = null;
SmartAppSettingService.Data.Launch = null;
return;
}
SmartAppSettingService.Data.Iss = Iss;
SmartAppSettingService.Data.Launch = LaunchCode;
SmartAppSettingService.Data.FhirServerUrl = Iss;
}
public async Task<bool> GetMetadataAsync()
{
Hl7.Fhir.Rest.FhirClient fhirClient = new Hl7.Fhir.Rest.FhirClient(SmartAppSettingService.Data.FhirServerUrl);
CapabilityStatement capabilities = (CapabilityStatement)(await fhirClient.GetAsync("metadata"));
foreach (CapabilityStatement.RestComponent restComponent in capabilities.Rest)
{
if (restComponent.Security == null)
{
continue;
}
foreach (Extension securityExt in restComponent.Security.Extension)
{
if (securityExt.Url != "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris")
{
continue;
}
if ((securityExt.Extension == null) || (securityExt.Extension.Count == 0))
{
continue;
}
foreach (Extension smartExt in securityExt.Extension)
{
switch (smartExt.Url)
{
case "authorize":
SmartAppSettingService.Data.AuthorizeUrl = ((FhirUri)smartExt.Value).Value.ToString();
break;
case "token":
SmartAppSettingService.Data.TokenUrl = ((FhirUri)smartExt.Value).Value.ToString();
break;
}
}
}
}
if (string.IsNullOrEmpty(SmartAppSettingService.Data.AuthorizeUrl) || string.IsNullOrEmpty(SmartAppSettingService.Data.TokenUrl))
{
return false;
}
return true;
}
public async System.Threading.Tasks.Task<string> GetAuthorizeUrlAsync()
{
var state = Guid.NewGuid().ToString("N");
SmartAppSettingService.Data.State = state;
await OAuthStateStoreService.SaveAsync<SmartAppSettingModel>(state, SmartAppSettingService.Data, TimeSpan.FromMinutes(10));
Console.WriteLine($"Generated state: {SmartAppSettingService.Data.State}");
string launchUrl = $"{SmartAppSettingService.Data.AuthorizeUrl}?response_type=code" +
$"&client_id={SmartAppSettingService.Data.ClientId}" +
$"&redirect_uri={Uri.EscapeDataString(SmartAppSettingService.Data.RedirectUrl)}" +
$"&scope={Uri.EscapeDataString("openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read")}" +
$"&state={SmartAppSettingService.Data.State}" +
$"&launch={SmartAppSettingService.Data.Launch}" +
$"&aud={Uri.EscapeDataString(SmartAppSettingService.Data.FhirServerUrl)}";
authUrlMessage = $"重新導向到授權伺服器:{launchUrl}";
return launchUrl;
}
}