2026年1月11日 星期日

FHIR 03 說明透過 EHR Launch 取得授權碼的程式碼做法與實測過程

 

FHIR 03 說明透過 EHR Launch 取得授權碼的程式碼做法與實測過程

在上一篇文章中 FHIR 02 建立 EHR Launch App ,我們已經成功建立了一個 EHR Launch App,並且在 EHR 系統中註冊完成。 接下來,我們將說明如何透過 EHR Launch 模式來取得授權碼 (Authorization Code),並且展示實際的程式碼做法與測試過程。

若這個系統可以正常運作,那麼將會來了解這個專案內的程式碼作法,並且說明 Launch 這個頁面的運作流程與程式碼設計過程。

執行結果說明

依照底下步驟操作,完成使用 Smart On FHIR 沙盒環境來進行 EHR Launch 的授權碼取得流程:

  • 開啟並且執行 [SmartEHRLaunch1]
  • 這裡是執行後的螢幕截圖,也就是 Blazor 開發框架的範例的啟動畫面 
  • 開啟沙盒驗證網頁 [https://thas.mohw.gov.tw/]
  • 這裡需要先進行身分驗證,點選 [登入] 文字連結
  • 在登入對話窗內,依序輸入帳號、密碼、驗證碼 
  • 最後點選 [登入] 按鈕,完成身分驗證作業
  • 點選上方的 [Sand Box] 連結,進入沙盒系統
  • 現在出現了 [SAND-BOX] 對話窗 
  • 對於 [請選擇] 下拉選單,選擇 [EHR Launch] 項目,也就是預設值
  • 在 [請輸入網址] 欄位中,輸入 https://localhost:7108/launch 
  • 其中, [https://localhost:7108/launch] 端點,就是剛剛撰寫程式碼的 Razor 元件頁面
  • 點選 [完成] 按鈕,系統會導向到 EHR Launch 頁面 
  • 在 [Launch] 端點頁面中,將會顯示出文字 [請稍後,正在初始化中]
  • 此時的網址列將會為 : https://localhost:7108/launch?iss=https://thas.mohw.gov.tw/v/r4/fhir&launch=WzAsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0
  • 這個網址將會是 Sandbox 系統導向到我們的 EHR Launch 頁面的網址,從這裡也看到了兩個重要的參數:
    • iss :代表 EHR 系統的 FHIR 伺服器位址 https://thas.mohw.gov.tw/v/r4/fhir ,這個將會是我們後續要與之互動的 FHIR 伺服器
    • launch :代表這次啟動的 Launch 參數,也就是 EHR 的系統所產生的 Launch Context
  • 接著,系統會自動將我們的瀏覽器,重新導向到授權伺服器: https://thas.mohw.gov.tw/v/r4/auth/authorize?response_type=code&client_id=smart-app&redirect_uri=https%3A%2F%2Flocalhost%3A7191%2FExchangeToken&scope=openid%20fhirUser%20profile%20launch%2Fpatient%20patient%2F%2A.read%20patient%2FEncounter.read%20patient%2FMedicationRequest.read%20patient%2FServiceRequest.read&state=15fb5f43e861447cbb482a399c4fe7ab&launch=WzAsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0&aud=https%3A%2F%2Fthas.mohw.gov.tw%2Fv%2Fr4%2Ffhir
  • 此時網頁畫面出會出現底下螢幕截圖,會有這樣的效果,是程式碼會暫停一段時間,故意讓使用者看到準備要切換到該網頁的畫面 
  • 現在,將會進入到 Sand Box 的授權伺服器,要取得授權碼,當取得了授權碼之後,就會使用 redirect_uri 參數,重新導向回到我們開發的頁面端點 https://localhost:7191/ExchangeToken ,並且在網址列中帶入授權碼參數 code
  • 在網頁上,將會看到下面的畫面截圖,準備進入到身分驗證階段,此時的 URL 為: https://thas.mohw.gov.tw/provider-login?response_type=code&client_id=smart-app&redirect_uri=https%3A%2F%2Flocalhost%3A7191%2FExchangeToken&scope=openid+fhirUser+profile+launch%2Fpatient+patient%2F*.read+patient%2FEncounter.read+patient%2FMedicationRequest.read+patient%2FServiceRequest.read&state=ab81aeae4fe84986bcca1e51948fa824&launch=WzAsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0&aud=https%3A%2F%2Fthas.mohw.gov.tw%2Fv%2Fr4%2Ffhir&login_type=provider 
  • 在這裡使用預設的帳號與密碼,點選 [Login] 按鈕
  • 現在的畫面將會切換到選擇病患的畫面,此時的網址為: https://thas.mohw.gov.tw/select-patient?response_type=code&client_id=smart-app&redirect_uri=https%3A%2F%2Flocalhost%3A7191%2FExchangeToken&scope=openid+fhirUser+profile+launch%2Fpatient+patient%2F*.read+patient%2FEncounter.read+patient%2FMedicationRequest.read+patient%2FServiceRequest.read&state=15fb5f43e861447cbb482a399c4fe7ab&launch=WzAsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0&aud=https%3A%2F%2Fthas.mohw.gov.tw%2Fv%2Fr4%2Ffhir&login_type=provider&login_success=1&provider=147533 
  • 點選任一病患項目,準備進入到下一個階段
  • 可是,卻看到的底下畫面 
  • 在此看到了 [無法連上這個網站] 的訊息,這是因為我們的 redirect_uri 端點 https://localhost:7191/ExchangeToken?code=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb250ZXh0Ijp7Im5lZWRfcGF0aWVudF9iYW5uZXIiOnRydWUsInNtYXJ0X3N0eWxlX3VybCI6Imh0dHBzOi8vdGhhcy5tb2h3Lmdvdi50dy9zbWFydC1zdHlsZS5qc29uIiwicGF0aWVudCI6InBhdGllbnQtVFdFTVItRE1TLTEyMzQ1Njc4OS1BMTIzNDU2Nzg5In0sImNsaWVudF9pZCI6InNtYXJ0LWFwcCIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOi8vbG9jYWxob3N0OjcxOTEvRXhjaGFuZ2VUb2tlbiIsInNjb3BlIjoib3BlbmlkIGZoaXJVc2VyIHByb2ZpbGUgbGF1bmNoL3BhdGllbnQgcGF0aWVudC8qLnJlYWQgcGF0aWVudC9FbmNvdW50ZXIucmVhZCBwYXRpZW50L01lZGljYXRpb25SZXF1ZXN0LnJlYWQgcGF0aWVudC9TZXJ2aWNlUmVxdWVzdC5yZWFkIiwicGtjZSI6ImF1dG8iLCJjbGllbnRfdHlwZSI6InB1YmxpYyIsInVzZXIiOiJQcmFjdGl0aW9uZXIvMTQ3NTMzIiwiaWF0IjoxNzY4MTI5MjYzLCJleHAiOjE3NjgxMjk1NjN9.yK4RJXRG34pjsdf53PhM6uiZnGOM89w0UdT6-8Wjd_8&state=15fb5f43e861447cbb482a399c4fe7ab
  • 會有這樣的結果,是因為我們上為設計 [ExchangeToken] 頁面程式碼,才會看到這樣的結果導致的
  • 對於 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb250ZXh0Ijp7Im5lZWRfcGF0aWVudF9iYW5uZXIiOnRydWUsInNtYXJ0X3N0eWxlX3VybCI6Imh0dHBzOi8vdGhhcy5tb2h3Lmdvdi50dy9zbWFydC1zdHlsZS5qc29uIiwicGF0aWVudCI6InBhdGllbnQtVFdFTVItRE1TLTEyMzQ1Njc4OS1BMTIzNDU2Nzg5In0sImNsaWVudF9pZCI6InNtYXJ0LWFwcCIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOi8vbG9jYWxob3N0OjcxOTEvRXhjaGFuZ2VUb2tlbiIsInNjb3BlIjoib3BlbmlkIGZoaXJVc2VyIHByb2ZpbGUgbGF1bmNoL3BhdGllbnQgcGF0aWVudC8qLnJlYWQgcGF0aWVudC9FbmNvdW50ZXIucmVhZCBwYXRpZW50L01lZGljYXRpb25SZXF1ZXN0LnJlYWQgcGF0aWVudC9TZXJ2aWNlUmVxdWVzdC5yZWFkIiwicGtjZSI6ImF1dG8iLCJjbGllbnRfdHlwZSI6InB1YmxpYyIsInVzZXIiOiJQcmFjdGl0aW9uZXIvMTQ3NTMzIiwiaWF0IjoxNzY4MTI5MjYzLCJleHAiOjE3NjgxMjk1NjN9.yK4RJXRG34pjsdf53PhM6uiZnGOM89w0UdT6-8Wjd_8 則是授權碼
  • 這個授權碼是以 JWT 格式所編碼的字串
  • 將過解譯這個 JWT 字串,將會看到這樣的 JSON 物件
{
  "context": {
    "need_patient_banner": true,
    "smart_style_url": "https://thas.mohw.gov.tw/smart-style.json",
    "patient": "patient-TWEMR-DMS-123456789-A123456789"
  },
  "client_id": "smart-app",
  "redirect_uri": "https://localhost:7191/ExchangeToken",
  "scope": "openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read",
  "pkce": "auto",
  "client_type": "public",
  "user": "Practitioner/147533",
  "iat": 1768129263,
  "exp": 1768129563
}
  • 從這裡看到的 [patient] 欄位值 patient-TWEMR-DMS-123456789-A123456789 ,這就是我們這次所選擇的病患資源 ID
  • 到此為止,我們已經成功透過 EHR Launch 模式,取得授權碼,接下來我們將會在下一篇文章中,說明如何使用這個授權碼,來交換取得存取權杖 (Access Token),並且使用這個存取權杖,來存取 FHIR 伺服器中的病患資源資料

專案服務與模型程式碼說明

SettingService.cs

  • 這個服務會注入 IOptions<SettingModel> 物件,來取得 [appsettings.json] 設定檔中的相關設定值
  • 在 [program.cs] 檔案中,已經完成這個服務的注入設定
#region 加入設定強型別注入宣告
builder.Services.Configure<SettingModel>(builder.Configuration
    .GetSection(MagicObjectHelper.SmartAppSettingKey));
#endregion
  • 這個服務內,只有一個方法 GetSettingAsync ,用來取得設定值物件
  • 在 [appsettings.json] 設定檔中,主要是要取得 [RedirectUrl] 這個值,用於當通過授權之後,要重新導向到原有專案內的頁面端點
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "SmartAppSetting": {
    "FhirServerUrl": "https://thas.mohw.gov.tw/v/r4/fhir",
    "RedirectUrl": "https://localhost:7191/ExchangeToken",
    "ClientId": "smart-app"
  }
}

SmartAppSettingService.cs

  • 這個服務將會用於儲存在 Launch 頁面中的狀態值,透過 .NET 提供的 [分散式快取] 機制來存取狀態物件
  • 在 [program.cs] 中,使用底下程式碼註冊了 [分散式快取] 服務
// 提供 IDistributedCache 的記憶體實作
builder.Services.AddDistributedMemoryCache();
  • 在 SmartAppSettingService 服務中,在建構式內注入了 SettingService 物件,因此可以取得 appsetting.json 內的設定值
public SmartAppSettingService(SettingService settingService)
{
    this.settingService = settingService;

    var data = settingService.GetValue();
    Data.FhirServerUrl = data.FhirServerUrl;
    Data.RedirectUrl = data.RedirectUrl;
    Data.ClientId = data.ClientId;
}
  • 在建構式內,這裡將 [FhirServerUrl] 、 [RedirectUrl] 、 [ClientId] 這三個值,設定到 SmartAppSettingModel 物件內,也就是可以用於要儲存到分散式快取的狀態物件內
  • 對於這個方法, UpdateSetting ,則是用來更新分散式快取內的狀態物件
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;
}

OAuthStateStoreService.cs

  • 這個物件將會提供 [分散式快取] 的存取功能
  • 在建構式內,注入了 IDistributedCache 物件
public OAuthStateStoreService(IDistributedCache cache) => _cache = cache;
  • 這個服務內,提供了三個方法,分別是 SaveAsync 、 LoadAsync 與 RemoveAsync
  • 這三個方法,分別用來儲存、載入與刪除分散式快取內的狀態物件,這裡使用了 JSON 序列化與反序列化的方式,來將物件轉換成字串,並且存取到分散式快取內

Launch.razor 頁面程式碼說明

  • 在這個頁面,使用了底下語法,來接收使用 Query String 參數傳遞過來的值
[SupplyParameterFromQuery(Name = "iss")]
public string? Iss { get; set; }
[SupplyParameterFromQuery(Name = "launch")]
public string? LaunchCode { get; set; }
  • 在 OnAfterRenderAsync 方法中,這裡會先檢查是否為第一次渲染頁面
  • 在第一次渲染事件發生的時候,將會執行底下的敘述
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);
    }
}
  • 這裡主要會進行:責處理 SMART on FHIR 啟動流程:保存啟動參數、取得 FHIR 伺服器元資料、產生授權 URL 並導向授權伺服器
  • KeepLaunchIss() 方法,保存 SMART on FHIR 啟動參數 (iss 和 launch), 從查詢字串中取得 iss (FHIR 伺服器 URL) 和 launch (啟動代碼) 參數並儲存到應用程式設定中
  • GetMetadataAsync() 方法,從 FHIR 伺服器取得元資料 (CapabilityStatement),解析 SMART on FHIR 的 OAuth 端點 (authorize 和 token URL) 並儲存到應用程式設定中
  • 最後將會,回傳一個布林值,表示是否成功取得授權和令牌端點
  • GetAuthorizeUrlAsync 方法,產生 OAuth 授權 URL,建立 state 參數以防止 CSRF 攻擊,將應用程式設定儲存到狀態存儲中,並組合完整的授權 URL,包含必要的 OAuth 參數:response_type、client_id、redirect_uri、scope、state、launch 和 aud
  • 最後將會,將瀏覽器導向到授權伺服器 URL,開始授權流程

GetMetadataAsync() 方法說明

GetMetadataAsync() 方法主要做三件事:

  • 連接 FHIR 伺服器 - 使用 FhirClient 向 FHIR 伺服器請求 metadata 端點,取得 CapabilityStatement (能力聲明)
  • 解析 OAuth 端點 - 在 CapabilityStatement 中尋找 SMART on FHIR 的 OAuth 擴充資訊 (oauth-uris),提取出:
    • authorize URL (授權端點)
    • token URL (令牌端點)
  • 驗證並回傳結果 - 將這兩個 URL 儲存到 SmartAppSettingService 中,並檢查是否都成功取得。若兩者皆不為空則回傳 true,否則回傳 false
  • 簡單來說,這個方法負責從 FHIR 伺服器探索並取得 OAuth 認證所需的授權和令牌端點 URL。

GetAuthorizeUrlAsync() 方法說明

GetAuthorizeUrlAsync() 方法主要做三件事:

  • 產生安全狀態碼 - 建立一個唯一的 state GUID 字串,用於防止 CSRF (跨站請求偽造) 攻擊
  • 儲存狀態 - 將應用程式的設定資料 (SmartAppSettingModel) 以 state 為鍵值存入 OAuthStateStoreService,保留 10 分鐘有效期限
  • 組合授權 URL - 建構完整的 OAuth 授權請求 URL,包含所有必要參數:
    • response_type=code (授權碼流程)
    • client_id (客戶端識別碼)
    • redirect_uri (回調網址)
    • scope (請求的權限範圍,如讀取病患資料)
    • state (安全狀態碼)
    • launch (SMART 啟動代碼)
    • aud (目標 FHIR 伺服器 URL)
  • 簡單來說,這個方法負責產生並回傳一個完整的 OAuth 授權 URL,用於將使用者重新導向到 FHIR 授權伺服器進行身份驗證。
  • 對於要取得授權碼的 URL,將會使用底下的程式碼
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)}";
  • 取得到這個 URL 之後,將會使用 NavigationManager.NavigateTo 方法,導向到授權伺服器,進行身分驗證,並且取得授權碼






沒有留言:

張貼留言