本文同步發表於個人部落格:解析 Launch Context
day05 提過火線超人有一支 Smart::LaunchContextService,專門解析 iss 和 launch:
def self.parse_launch_params(params)
{
iss: params[:iss],
launch: params[:launch],
patient: params[:patient],
# ...
}
end
當時的重點是「這兩個參數只有被別人啟動時才會出現」。而火線超人住在 LINE 裡,走的是 standalone。這支服務的前半段用不到。
但它的回傳值裡還有一個 patient。那一半是用得到的,而且不管走哪條路都用得到。因為授權完成之後,兩種模式都會遇到同一個問題。你手上有一串 id,畫面上要的是一個人。
day11 拿到的那個欄位長這樣:
patient: "d48ac962-78c6-46cf-ba33-a24771bfa0e4"
它不是姓名、不是生日、不是任何可以顯示的東西,就是一個主鍵。要變成畫面上的「王小明,1968 年生」,你得拿它再去查一次:
GET {fhirBaseUrl}/Patient/d48ac962-78c6-46cf-ba33-a24771bfa0e4
Authorization: Bearer {access_token}
這一趟是必要的。token response 不會附上病人資料,它只告訴你「這次授權的是哪一位」。
順帶一提,這也是 patient/Patient.r 這個 scope 存在的理由。你以為拿到 patient id 就等於拿到病人。其實你還需要一個讀 Patient 資源的權限,兩件事是分開的。day10 那個「反推」的練習如果漏了這一項,程式會在這一步 403。
token response 上跟 context 有關的欄位,這台實際會回的有這些:
| 欄位 | 內容 | 什麼時候有 |
|---|---|---|
patient |
病人 id | 送了 launch/patient 或 launch |
encounter |
就診事件 id | 這次只有 EHR Launch 那趟回了 |
need_patient_banner |
布林值 | 一直都有 |
smart_style_url |
一個 JSON 網址 | 一直都有 |
need_patient_banner 講的是你要不要自己顯示病患基本資料列。這個詞的原文是 patient banner,指醫院系統畫面上方固定的那一條。上面寫著姓名、病歷號與警示標記,臨床人員靠它確認開的是哪位病人。
方向別記反。true 是你要自己顯示,false 是 EHR 已經顯示了,你可以省下那塊空間。規範對 false 的措辭留有餘地,寫的是「可能不需要」。
這台回的是 true。合理,因為我們走 standalone。app 開在自己的分頁裡,旁邊沒有 EHR 的畫面可以靠。
smart_style_url 才是給嵌在 EHR 裡的 app 用的。那個網址回傳一組色票,讓你的 app 看起來像 EHR 的一部分。standalone 用不上這一項,但你會一直看到它出現在欄位清單裡。
encounter 那一列才是重點,它牽出兩種模式真正的差別。
我把 EHR Launch 也實際跑了一次,跟 standalone 擺在一起:
| standalone | EHR Launch | |
|---|---|---|
| 送的 context scope | launch/patient |
launch |
patient |
有 | 有 |
encounter |
沒有 | 有 |
fhirUser 指向 |
Patient/<id> |
Practitioner/<id> |

差別不在於誰比較完整,在於 context 是誰決定的。
standalone 是使用者自己開的。這次我們只送了 launch/patient,所以回來的只有病人。規範另外有 launch/encounter,它沒有被限定只能用在 EHR Launch。不過就算送了,EHR 也可以忽略這類 hint。
實際上你不太會在 standalone 送 launch/encounter。使用者是在自己家裡打開 app,他不在任何一次就診裡面。
EHR Launch 送的是一個沒有斜線的 launch。規範給的是一項權限:從 EHR 啟動時可以取得 launch context。至於要哪幾個 context,規範的寫法是各自用 launch/patient 這類 scope 去要。
但這台只收到一個 launch,就把 patient 和 encounter 都給了。這就是 day11 說的協商,伺服器可以主動給你沒有要求的 context。
醫師點下去的那一刻,他正開著某位病人的某一次就診,這兩件事都在那個 launch 參數裡。所以 encounter 是跟著醫師的操作情境來的,不是 app 要來的。
fhirUser 那一列也一樣。standalone 登入的是病人本人,EHR Launch 登入的是醫師。同一個 claim,指向不同的資源型別。明天整篇講 fhirUser。
從 day06 到今天,授權流程的每一段我們都親手寫過。discovery 問端點、PKCE 產 verifier 與 challenge。組 authorize URL、驗 state、拿 code 換 token。
該收工了。今天開始改用 fhirclient。
不是因為手刻很麻煩,是因為機制已經看過了。
如果一開始就用函式庫,code_challenge 對你來說就只是一個「照抄就會動」的參數。等到某天授權失敗,你會完全不知道從哪裡查起。現在不一樣,你知道 verifier 存在 sessionStorage。知道 state 是拿來比對的,知道 token 是 POST 到哪裡換的。這些理解不會因為換成函式庫而消失。
我直接翻了 vendor/ 裡那個檔案。也就是說:
| 我們手刻的 | fhirclient 的處理 |
|---|---|
discovery.js 問端點 |
內建,你只要給 iss |
pkce.js 產 verifier 與 challenge |
內建,S256 |
sessionStorage 存 verifier |
內建 |
state 產生與比對 |
內建 |
fetch 換 token |
內建 |
| 每個 FHIR 請求手動加 header | client.request() 自動帶 |
還多給你幾樣我們沒寫的。自動分頁(pageLimit)、自動解參照(resolveReferences)、token 過期時的處理。

還記得 day04 建專案時 index.html 裡有兩個 script 標籤嗎?
<script src="vendor/fhir-client.pure.min.js"></script>
<script type="module" src="app.js"></script>
那個 54 KB 的檔案躺了八天,day06 說「先留著,day12 會用回來」。就是今天。
起點:day11 之後的 smart-app,有 index.html、app.js、discovery.js、pkce.js、auth.js、vendor/fhir-client.pure.min.js。
產出:app.js 改用 fhirclient 走完授權,畫面顯示病人姓名。discovery.js、pkce.js、auth.js 不再被引用,但先不要刪。第三步的 EHR 模式只換啟動方式,檔案一個字都沒動。day13 接的是第二步那個 standalone 的畫面,不是 EHR 那一次。
整份替換成這樣:
export const FHIR_BASE_URL =
'https://launch.smarthealthit.org/v/r4/sim/WzMsIiIs…/fhir'
const SCOPE = 'launch/patient patient/Patient.r openid fhirUser'
const CLIENT_ID = 'my-smart-app'
const result = document.querySelector('#app')
const connectButton = document.querySelector('#connect')
FHIR.oauth2
.ready()
.then(showPatient)
.catch(() => {
// 還沒授權過,ready() 會 reject,這時候才顯示按鈕
result.textContent = '準備好了,按按鈕開始授權'
connectButton.disabled = false
connectButton.addEventListener('click', () => {
FHIR.oauth2.authorize({
iss: FHIR_BASE_URL,
clientId: CLIENT_ID,
scope: SCOPE,
redirectUri: window.location.pathname,
})
})
})
async function showPatient(client) {
connectButton.remove()
console.log('patient id:', client.patient.id)
console.log('encounter id:', client.encounter.id)
console.log('fhirUser:', client.user.fhirUser)
const patient = await client.patient.read()
const name = patient.name?.[0]
const display = name ? `${name.given?.join(' ')} ${name.family}` : '(這筆資料沒有姓名)'
result.textContent = `${display},生日 ${patient.birthDate}`
}
三十行不到,取代了前面六天寫的四個檔案。注意選項名稱是 clientId 和 redirectUri 這種寫法,不是我們手刻時用的 client_id。
SCOPE 也換了一項。day10 到 day11 練習用的是 patient/Observation.rs,今天要讀的是病人本身,所以改成 patient/Patient.r。這就是前面說的,拿到 patient id 跟讀得到 Patient 資源是兩件事。
FHIR.oauth2.ready() 是整段的樞紐。它會去看網址列上有沒有 code。有就自動完成換 token 並回傳一個 client,沒有就 reject。所以「還沒授權」這件事是走 catch 分支的,第一次讀會有點反直覺。
起靜態伺服器,走完授權。畫面應該從 id 變成人:
Jerrell Gerlach,生日 1968-04-19
console 那三行:
patient id: d48ac962-78c6-46cf-ba33-a24771bfa0e4
encounter id: null
fhirUser: Patient/d48ac962-78c6-46cf-ba33-a24771bfa0e4
encounter id 是 null,因為 standalone 沒有就診事件。fhirUser 指向 Patient,因為登入的是病人本人。
這一步不用改程式碼,只換啟動方式。
http://localhost:5173/(你的本機位址)你的 app 會被 Launcher 開起來,網址列上帶著 iss 和 launch。fhirclient 會自己讀走這兩個參數,你不用寫任何一行去接。
這次 console 變成:
patient id: d48ac962-78c6-46cf-ba33-a24771bfa0e4
encounter id: 7ac26195-115e-4593-84d1-19170a042dfe
fhirUser: Practitioner/4826865
同一份程式碼、同一個 SCOPE,只因為啟動方式不同,拿到的 context 就不一樣。

預期結果:standalone 顯示病人姓名、encounter 為 null、fhirUser 指向 Patient;EHR 模式下 encounter 有值、fhirUser 指向 Practitioner。
如果 EHR 模式跑不起來,先確認 Launch URL 填的是 http://localhost:5173/ 而不是 file://。也要確認靜態伺服器正在跑。
discovery.js、pkce.js、auth.js 現在沒有人 import 了,但留著。
day14 講 token 生命週期時,我們會再回到 HTTP 層。那時候要剖開 refresh 的那一趟交換。auth.js 裡換 token 的那段程式碼是最好的對照組。函式庫幫你做掉的事情,你得看得懂它在做什麼。
token response 裡的 patient 是一串 id,不是一個人。要變成畫面上的姓名得再查一次 Patient/{id},而那一趟需要 patient/Patient.r 這個 scope。
context 的內容由啟動方式決定。standalone 只要得到病人,EHR Launch 連就診事件一起帶進來。因為那包 context 是醫師點下去那一刻就決定的,不是 app 要來的。
今天也把授權流程交給了 fhirclient。它接手 PKCE、state、token 交換與 discovery。也就是 day06 到 day09 我們親手寫過的每一件事。先手刻再換工具,順序反過來的話,你只會得到一堆照抄的參數。
fhirUser 今天出現了兩次,一次指向 Patient、一次指向 Practitioner。明天就從這個 claim 開始,看 app 怎麼知道現在是誰在用它。