scope 解鎖哪些欄位
昨天那份火線超人的預設清單,我們只看了前面五行:
DEFAULT_SCOPES = %w[
patient/Patient.read
patient/Observation.read
patient/Condition.read
patient/MedicationStatement.read
patient/Encounter.read
launch/patient
openid
fhirUser
].join(' ').freeze
後面三行才是今天的主角。它們跟前面五行擺在同一個陣列裡,語法卻完全不一樣。都沒有帶「.」,也不對應任何一種 FHIR 資源。
還有一件事,從這份清單本身就看得出來:它沒有 offline_access。
這代表火線超人拿不到 refresh token。access token 過期使用者就得再走一次授權。對一個住在 LINE 裡的 app 來說,這是在對話中間就會被打斷的體感。
我不知道當初是刻意還是漏掉,這份清單只告訴我它不在。但它剛好示範了一件事:這幾個 scope 少要一個,少掉的不是資料,是能力。
它們要的不是資料,是欄位
昨天那種分成三個部分的 scope 決定「這張 token 能碰什麼資料」。今天這幾個決定的是另一件事。token response 這份 JSON 上會多出哪些欄位。
少要一個 patient/Observation.rs,是拿不到檢驗資料;少要一個 launch/patient,是連「現在是哪位病人」都不知道。前者影響能力範圍,後者影響程式跑不跑得起來。
在看表之前先說清楚一件事。這些欄位不是你送了 scope 就一定拿得到。
規範把這件事定義成一場協商。app 提出想要哪些 launch context,伺服器決定給哪幾個。伺服器甚至可以回傳你沒有要求的 context。下面這張表是我在 SMART Health IT Launcher 上跑的。不是每一台都這樣。
我把十種組合實際跑了一遍,只換 scope,其他參數完全不動:
| 送出的 scope | patient |
id_token |
refresh_token |
|---|---|---|---|
patient/*.rs |
無 | 無 | 無 |
launch/patient patient/*.rs |
有 | 無 | 無 |
patient/*.rs openid fhirUser |
無 | 有 | 無 |
patient/*.rs offline_access |
無 | 無 | 有 |
| 以上全送 | 有 | 有 | 有 |
這台的對應很整齊。要哪個欄位,就送對應的那個 scope。
沒送的時候呢?那個 key 根本不存在,不是給你一個空值。 待會的跟著做會讓你親眼看到這件事怎麼安靜地爆掉。
但整齊的是這台,不是規範。換一台伺服器,要看它實際核發了哪些 scope。也要看 token response 回了哪些欄位。

launch/patient:病人是誰
launch/patient 長得像有斜線,但它不是昨天那種三個部分的寫法。斜線後面接的是 context 的種類,不是資源型別。
它跟另一個更短的 launch 是一對。差別在哪,day05 那兩種啟動模式的圖可以直接搬過來用:
launch |
launch/patient |
|
|---|---|---|
| 用在 | EHR Launch | Standalone Launch |
| 意思 | 請給我這次啟動帶進來的 context | 請讓使用者選一位病人 |
| context 哪來 | launch 參數換回來的 |
授權過程中選的,或依登入身分決定 |
launch 用在 EHR Launch 那條路上。它是在說「我有 launch 參數,請把它代表的 context 給我」。context 在醫師點下去那一刻就決定了,app 沒得選。
standalone 沒有那個參數。所以 launch/patient 是在說「請在授權流程裡讓使用者選一位」。也就是我們前幾天看到的 Patient Login 畫面。
前面說的協商在這裡有個具體例子:launch/patient 在 EHR Launch 底下只是 hint。我們走 standalone,送就對了。
這台的第一列不符規範
回頭看表上第一列。只送 patient/*.rs,patient 欄位是空的。
規範這裡說得很明確。app 要了限定單一病人的資源型 scope,而伺服器核發了它。這時候伺服器就該建立一個 patient context。 SHALL,不是建議。
規範給了伺服器兩條路。一條是拒絕這種沒帶 launch 的請求。另一條是自己推定 launch/patient,把病人選擇流程補上。
這台兩條都沒走。照收 patient/*.rs,scope 欄位原樣回傳,然後不給 patient context。
你不太會真的這樣送,要病人資料本來就會配 launch/patient。但換一台伺服器,同一組 scope 可能被拒絕。
openid 與 fhirUser:使用者是誰
這兩個是 OpenID Connect 那一套進到 SMART 裡的部分。它們打開的是 id_token。
access_token 和 id_token 常被搞混,講清楚一次:

day09 提過這台的 access_token 剛好也是 JWT,但那是實作細節,SMART 沒有要求它是。id_token 裡面有一個 fhirUser claim,指向操作者的 FHIR 資源:
patient-standalone fhirUser = Patient/d48ac962-78c6-46cf-ba33-a24771bfa0e4
provider-standalone fhirUser = Practitioner/4826865
同一個 claim,因為登入的人不同而指向不同的資源型別。這是「這個 app 現在是誰在用」的答案,day13 會整篇拆它。
這台要兩個一起送
實測到一個跟規範不一樣的地方,得說清楚。
規範的寫法是 openid 就會拿到 id_token,fhirUser 的作用是讓你能從 claim 取得使用者的資源位址。也就是說這兩個是不同層次的東西。前者決定「發不發」,後者決定「裡面有沒有那個資訊」。
但這台 Launcher 不是這樣:
| 送出的 scope | 有沒有 id_token |
|---|---|
openid |
沒有 |
patient/*.rs openid |
沒有 |
patient/*.rs fhirUser |
沒有 |
openid fhirUser |
有 |
單獨送任一個都不發,兩個一起才發。
實務上這不太會咬到你,要使用者身分時你本來就會兩個一起送。你在別的伺服器上只送 openid 卻拿到 id_token,那不是它壞了,那才是規範寫的行為。
offlineaccess 與 onlineaccess:token 過期之後
這兩個都在要同一樣東西,refresh_token。差別在它能活多久。
規範的定義是這樣:
offline_access:拿一個 refresh token,不管使用者在不在線上,只要授權伺服器和使用者允許,它就一直可用online_access:拿一個 refresh token,只在使用者還在線上的期間可用
差別是語意上的,不是格式上的。我兩個都送了一次,這台都給了 refresh_token,從單次回應完全看不出差異。因為「使用者離線」這件事要等到 session 結束才觀察得到。
所以這裡我只能說規範怎麼定義,不能拿實測結果宣稱兩者等價。
該用哪一個,看你的 app 什麼時候需要資料:
- 使用者開著頁面在用,關掉就算了 →
online_access - 背景同步、排程通知、使用者早就離開了還要繼續跑 →
offline_access
火線超人是後者的典型場景,它要在使用者沒有打開 LINE 的時候推播檢驗結果。但它的清單裡沒有 offline_access,這就是開頭說的那件事。
fhirContext:病人與就診事件以外的東西
patient 和 encounter 是固定欄位,但 context 不會只有這兩種。醫師從一張影像報告點開你的 app,那張報告本身也是 context。
SMART v2 為此加了 fhirContext。這是一個陣列,放固定欄位以外的資源參照:
{
"patient": "123",
"fhirContext": [{ "reference": "ImagingStudy/123" }]
}
每個元素至少要有 reference、canonical、identifier 其中一個,可以再帶 type 和 role。
Patient 和 Encounter 原則上不放進來,它們有自己的欄位。例外在 role 上。role 沒填等同於 "launch",這種不准放。但 role 給的不是 "launch" 呢?那就可以放進陣列裡。
fhirContext 是選用的,這台 sandbox 沒有回。會用到的多半是 EHR Launch 底下的臨床工具。standalone 主線用不上,知道有這條路就好。
跟著做:一次拿掉一個,看欄位怎麼消失
起點:day10 之後的 smart-app,auth.js 的 SCOPE 是 launch/patient patient/Observation.rs,能走完一次授權。
產出:你會親眼看到三個非資源型 scope 各自對應哪個欄位。那些欄位都在 token response 上。而且你會知道少送一個會讓程式在哪裡爆掉。app.js 只多一行觀察用的 console.log,六個檔案的組成沒有變。day12 會把 app.js 整份換掉。
第一步,先把欄位印出來
app.js 那幾行 console.log 底下加一行:
console.log('token response 的欄位:', Object.keys(token))
這一行是今天的觀察窗。前面幾天我們只印自己要的欄位,看不到「有哪些欄位不見了」。
第二步,跑一次基準
SCOPE 保持 day10 的值不動,走完授權,看 console:
token response 的欄位: ['access_token', 'expires_in', 'need_patient_banner',
'patient', 'scope', 'smart_style_url', 'token_type']
七個欄位,patient 在裡面。
第三步,拿掉 launch/patient
export const SCOPE = 'patient/Observation.rs'
重跑。這次登入畫面之後,patient 那個欄位不見了,而且 app.js 最後那行畫面文字會變成:
授權完成,patient id 是 undefined
這就是前面說的那個 undefined。它不會拋錯,程式看起來跑完了,錯誤要到你拿它去組查詢網址才會出現。
第四步,一次加一個回來
依序改成這三個值,每次重跑一次,只看欄位清單怎麼變:
'patient/Observation.rs openid fhirUser' // 多出 id_token
'patient/Observation.rs offline_access' // 多出 refresh_token
'launch/patient patient/Observation.rs openid fhirUser offline_access' // 九個全到
最後那一組就是 day09 給你的那串的 Observation 版本,兜了一圈回來。
預期結果:欄位清單隨 scope 增減,這台的三組各對一個欄位。而且你會發現少送 launch/patient 時程式不會報錯,只會安靜地拿到 undefined。

常用組合表
常見的幾種組合整理成一張表,照場景挑:
| 場景 | scope 組合 |
|---|---|
| 病人自己的健康管理 app,只讀 | launch/patient patient/Observation.rs patient/Patient.r |
| 同上,要知道使用者是誰 | 再加 openid fhirUser |
| 同上,要背景同步 | 再加 offline_access |
| 醫師診間工具,從 EHR 啟動 | launch user/Observation.rs openid fhirUser |
| 夜間批次對接 | system/Observation.rs,走 client_credentials,沒有使用者 |
挑的方法就兩句:資源型的照 day10 反推,不要用 *;非資源型的照欄位需求加,要哪個欄位就加哪個。其中 offline_access 最該想清楚。一張長期有效的 refresh token 等於一把長期有效的鑰匙。怎麼存是 day14 整篇的題目。
小結
今天這四個 scope 沒有一個是在要資料。它們要的是 token response 上的欄位:launch/patient 換 patient、openid 加 fhirUser 換 id_token、offline_access 換 refresh_token。
在這台是一對一,少送就沒有。而且沒有的時候程式不會報錯,只會給你 undefined。
這台跟規範對不上的地方今天出現兩次。一是 openid 要配 fhirUser 才發 id_token。二是核發了 patient/*.rs 卻不給 patient context。處理方式一律是照規範寫、照實際行為測。
火線超人那份清單裡沒有 offline_access,代價是 token 過期就得重新授權一次。而這件事光看那份清單就讀得出來。
那麼,launch/patient 換回來的 patient 欄位,內容只是一串 id:
d48ac962-78c6-46cf-ba33-a24771bfa0e4
畫面上總不能顯示這個。明天就把這串 id 變成一個真正的病人。