本文同步發表於個人部落格:SMART 的兩種啟動模式
火線超人的前端是 LINE。
這句話聽起來只是個技術選型,但它其實一開始就把一件事決定了:這個 app 沒有 EHR 可以待在裡面。使用者是在 LINE 的聊天室裡點開它的,不是在醫院診間的病歷系統裡點開它的。醫院的系統根本不知道有這個 app 存在。
翻開專案裡的 FhirOauthService,授權的進入點長這樣:
def initiate_authorization(fhir_server: nil, callback_url:, scope: nil)
initiate 這個字就是重點。授權是這個 app 自己發起的。先挑一台 FHIR 伺服器,自己組出授權網址,自己把使用者送過去,從頭到尾沒有第二個系統參與。
同一個專案裡還躺著另一支服務 Smart::LaunchContextService,它解析的是 iss 和 launch 這兩個參數:
def self.parse_launch_params(params)
{
iss: params[:iss],
launch: params[:launch],
patient: params[:patient],
# ...
}
end
這兩個參數,只有在「被別人啟動」的時候才會出現,LINE 這條路走不到它們。會有這一支,是因為我當初根本分不清楚這兩種模式差在哪,乾脆兩種都寫。結果就是兩條路在程式碼裡都留了位置,實際跑起來只用得到其中一條。
那時候要是有人先把差別講清楚,我可以少寫一半。今天就來把這兩條路攤開看。
SMART App Launch 定義了兩種啟動方式。名字很直白:EHR Launch(從 EHR 裡啟動)和 Standalone Launch(自己啟動)。
差別不在授權流程本身,兩者換 token 的方式一模一樣,都是我們後面幾天要拆的授權碼流程。差別在誰來啟動,以及app 一開始知道多少事。
想像醫師正在看某位病人的病歷,畫面上有一排小工具按鈕,其中一個是你的 app。醫師點下去,你的 app 在 EHR 裡開了一個視窗。

關鍵是 EHR 開啟你的網址時帶了兩個參數:
iss:這家醫院的 FHIR 伺服器位址。app 不用事先知道自己會被哪家醫院啟動launch:一個不透明的識別碼。你看不懂它的內容,但把它原樣帶去授權伺服器,對方就知道「這次啟動是醫師在看某某病人時發起的」launch 是這個模式的靈魂。它像是一張寄物櫃的號碼牌,牌子本身沒有資訊,但拿著它去櫃檯,就能領出「現在的病人是誰、在哪個就診事件」這些 context。app 什麼都不用問,token 回來時病人就在裡面了。
醫師也不會看到同意畫面,因為他在 EHR 裡已經登入過,app 也早就註冊在系統裡了。
沒有 EHR 可以待的 app 走這條。使用者從桌面圖示、瀏覽器書籤,或者像火線超人一樣從 LINE 的選單點開它。

開場的是使用者自己,所以沒有 launch 參數,多了登入與同意,攤成訊息就是圖上那四趟。如果 app 需要知道「現在要看哪位病人」,得由使用者自己選,或者由授權伺服器依登入身分決定。
火線超人就是後者,使用者是病人本人,登入完成後 context 裡的病人自然就是他自己。
多出來的那四趟不是缺點,是責任的移轉:醫院那端已經替你確認過使用者是誰了。
| EHR Launch | Standalone Launch | |
|---|---|---|
| 誰來啟動 | EHR 開啟你的網址 | app 自己啟動 |
| 起始參數 | iss 加 launch |
沒有,你得自己知道要連哪台 |
| 使用者登入 | 已經在 EHR 裡登入過了 | 要在授權伺服器登入一次 |
| 病人 context 哪來 | launch 換回來的 |
登入身分或使用者自選 |
| 典型場景 | 醫師在診間用的臨床工具 | 病人自己的健康管理 app |
| 需要先跟醫院註冊嗎 | 要,EHR 得知道你的啟動網址 | 要,但只需註冊 redirect 網址 |
表格裡沒列的還有一個參數:aud(audience)。app 去要授權時除了自報身分,還得說清楚「這張 token 要拿去存取哪一台 FHIR 伺服器」。兩種模式都要送,不是 standalone 專用。
差別只在值從哪裡來。EHR launch 把 iss 原封不動抄過去;standalone 沒人告訴你,只能填自己設定的那台。
為什麼要多這一道手續?因為 token 是不記名的票,誰拿到誰能用。規範對 aud 的說明很直白:防止把真的 token 洩漏給假的資源伺服器。有人架一台假 FHIR server 騙你的 app 去授權,沒宣告目的地就會拿到一張到處能用的票;宣告了,授權伺服器就會擋掉不歸它管的目標。
這兩種模式不是二選一的陣營。判斷方式很簡單,看啟動網址上有沒有 launch 參數:
const params = new URLSearchParams(location.search);
const iss = params.get('iss');
const launch = params.get('launch');
if (iss && launch) {
// EHR launch:伺服器位址是對方給的,授權時要把 launch 原樣帶上
startAuthorization({ fhirBaseUrl: iss, launch });
} else {
// standalone:使用者自己開的,伺服器位址得自己知道
startAuthorization({ fhirBaseUrl: MY_DEFAULT_FHIR_SERVER });
}
真正麻煩的不是這個 if,是兩條路的 scope 與 context 不一樣,後面幾天會展開。
表格最後一列的「註冊」值得多說一句。不管走哪條路,app 都得先在對方的授權伺服器登記,至少要留一組 client id 和 redirect URI。redirect URI 是授權完成後使用者被送回的位址,伺服器只會把授權碼送到登記過的位址,一個字都不能差,本機開發改個埠號就得回去改註冊資料。EHR launch 還要多登記啟動網址,因為 EHR 得知道那排按鈕點下去要開哪一頁。上線後每家醫院還要各註冊一次,拿到各自的 client id。
只是對火線超人來說,支援 EHR launch 沒有意義。它的使用者永遠是從 LINE 進來的,不會有哪家醫院的病歷系統去啟動一個聊天機器人。所以 LaunchContextService 留了解析 iss 和 launch 的能力,實際跑的卻是 standalone 那條。
接下來九篇的可跟著做程式碼,一律走 standalone launch。
理由很現實:standalone 授權完成後,使用者會被送回你自己的 localhost 頁面,本機開發環境直接就能跑。EHR launch 得先讓 Launcher 內嵌開啟你的 app、承接 iss 和 launch,還要應付 iframe 的各種狀況,時間會花在跟授權原理無關的除錯上。
EHR launch 不會被跳過。等到 day12 講 launch context 的時候,我們會用 Launcher 的 EHR 模式實際跑一次,看 launch 參數怎麼把病人帶進來。今天先知道差別在哪就夠了。
今天不寫程式。打開 SMART Health IT Launcher,兩種模式各跑一次,重點全部在網址列。
第一次,EHR Launch:
https://example.org/launch。這個網域是保留給示範用的,會回一個乾淨的靜態頁面,正好當一個「什麼都不做的 app」你會看到這樣一串(launch 的值每次不一定相同):
https://example.org/launch
?iss=https%3A%2F%2Flaunch.smarthealthit.org%2Fv%2Fr4%2Ffhir
&launch=WzAsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0
這就是 EHR 交棒的那一刻。頁面上只寫著 Example Domain,什麼事都沒發生,但兩個參數已經送到了。真正的 app 會在這裡接手:把 iss 記下來、把 launch 原樣帶去授權伺服器。
想看完整流程的話,把 Launch URL 清空,改按下面的 Launch Sample App。這次會先要你挑一位醫師登入、再挑一位病人,Sample App 才開起來。停在登入畫面時看一下網址列,那一長串就是 app 送出的授權請求,裡面有 launch,也有 aud,而 aud 的值跟剛才的 iss 一模一樣。全程不會出現同意畫面。等 app 開起來,網址列又變回乾淨的 /sample-app,參數被 app 自己清掉了,因為它們留在網址列上會進瀏覽器歷史,也會跟著複製貼上跑到別的地方。
第二次,Standalone Launch:
欄位標題變了,從 App's Launch URL 變成 Server's FHIR Base URL,內容變成唯讀,旁邊多了一顆 Copy 按鈕。
這一個改變就把差別講完了。EHR launch 模式下,Launcher 問的是「你的 app 在哪裡」,因為等一下要由它去開你的 app;standalone 模式下換成它給你一串位址,因為這次是你要來找它。
這次會先看到 Patient Login,登入後還有一張列出權限的 Authorize App Launch,按 Approve 才進得去。這兩步就是沒有 EHR 代勞之後多出來的成本。一樣在登入畫面看網址列,launch 不見了,aud 換成那串含 /sim/ 的長位址。
兩串編碼其實是同一份設定
把剛才兩個值擺在一起:

/sim/ 後面那一段跟 launch 參數幾乎一樣,只有第三個字元不同(WzAs 對 WzMs)。它們是同一份設定的兩種放法:base64 編碼過的 JSON 陣列,第一個數字就是啟動類型,0 是 Provider EHR Launch,3 是 Patient Standalone Launch。
這也解釋了 standalone 的位址為什麼那麼長:模擬設定總得有地方放,EHR launch 塞在 launch 裡,standalone 只好編進路徑。
要強調的是,能解開是這個 Launcher 的實作細節,不是標準行為。SMART 規定 launch 對 app 而言是不透明的,你該做的只有原樣轉交,不要去解它,更不要依賴裡面的內容。
這個細節明天就會派上用場。如果你把那串乾淨的 /v/r4/fhir 拿去當 standalone 要連的伺服器位址,授權伺服器會直接把你退回來,錯誤訊息是 Invalid launch options。明天我們就會踩到這個坑,然後爬出來。
預期結果:兩種模式的網址列你都看過了,而且說得出「誰來啟動」怎麼一路影響到 Launcher 的欄位標題、登入畫面與同意畫面。
今天沒動到程式碼,但決定了後面九篇的路線:走 standalone launch,因為它的每一步都能在你自己的機器上完成。
兩種模式的分水嶺就一件事,誰來啟動。由 EHR 啟動,它會把 iss 和 launch 交到你手上,病人是誰對方直接告訴你;app 自己啟動,你得自己知道要連哪台伺服器,使用者也得自己登入一次。
火線超人選 standalone 不是評估比較後的結果,是住在 LINE 裡的必然。這也提醒我們一件事:啟動模式往往不是技術偏好決定的,是產品長在哪裡決定的。
那麼問題來了。standalone 是自己出發的,我們怎麼知道某台 FHIR 伺服器的授權網址在哪、token 要往哪裡換?總不能每家醫院都寫死在程式裡吧。
明天談 discovery,看看怎麼「問」出這些端點。