一組設定打天下行不通
火線超人的資料庫裡有一張資料表,每一台 FHIR 伺服器都紀錄一筆。
每一筆紀錄裡面有三個跟授權有關的欄位:client_type、client_id、client_secret,最後那個是加密存的。client_type 只有兩種值,public 或 confidential_symmetric。
為什麼要存 client_type?因為換一家醫院,連 client 的種類都可能不一樣。
本系列從 day08 教到現在都是 public client 加 PKCE。理由是純前端沒有地方藏 secret。火線超人有後端,所以它能支援 confidential client。而且真的有一台伺服器要求這樣接。那台的 token 交換請求裡,client_secret 要跟 client_id 一起送出去,而 PKCE 的 code_verifier 該帶還是要帶。
每家醫院給你的不只是不同的 client_id。今天要講的就是這件事。
為什麼不能共用一組設定
直覺會想:既然 SMART 是標準,那我註冊一次,到處都能用吧。
事情不是這樣的,而且原因不只一個。
client 是在每一家各自註冊的。 你去 A 醫院申請,拿到的是 A 醫院發給你的 client_id。B 醫院的授權伺服器根本沒有這個 id 的紀錄。
redirect URI 是註冊時綁定的。 每一家都要你把 callback 網址填進去,而且只有填過的那些才會被接受。這是 OAuth 的基本安全機制,防止有人把 code 導去別的地方。
scope 是各自談設定的。 day10 講過 scope 是申請不是保證。A 醫院願意給 patient/*.rs,B 醫院可能只給 patient/Patient.rs 加 patient/Observation.rs。你要記住哪一家給了什麼,才不會在 B 醫院上查一個註定失敗的資源。
端點位址也不一樣。 這件事最容易被忽略。授權端點跟 token 端點是各家自己的,day22 會再講它們的快取機制。
開兩家醫院來試
要驗證這些,得有兩台伺服器。
SMART Launcher 可以開好幾份不同的模擬設定,每一份就是一組獨立的端點。我開了兩份,在文章裡叫 A 醫院跟 B 醫院:
| A 醫院 | B 醫院 | |
|---|---|---|
client_id |
hospital-a-client |
hospital-b-client |
| scope | launch/patient patient/*.rs openid fhirUser offline_access |
launch/patient patient/Patient.rs patient/Observation.rs |
| 病人 | Abdul Koepp,male,1956-08-03 | Renea Quigley,female,1957-12-26 |
B 醫院刻意設得比較窄,窄在三個地方。一是沒有 openid,二是沒有 offline_access,三是資源只開兩種。這不是故意刁難,而是實際運作就是這樣。有些醫院不給你身分資訊,有些不給長期存取。
兩份設定的 .well-known/smart-configuration 各自回 200,而且有一個地方要分清楚。兩邊的 scopes_supported 都是 9 項,內容也相同。那一欄講的是 Launcher 這台伺服器支援什麼。真正決定你拿得到什麼的是 scope。那是註冊時談好的,也就是上面那張表兩家不同的那一列。

先說一下開兩份設定的限制。 兩份模擬設定背後是同一台伺服器。所以測不出真實跨醫院的差異。一是 FHIR 版本,二是每家有填的欄位不一樣,三是某一家半夜維護。那些差異只能靠經驗累積,day24 講跨伺服器整合時會再來說明。
同意畫面看得出差別
兩家授權時的同意畫面不一樣,這是 scope 差異可以明顯看得出來的地方。
A 醫院有一句 offline 的說明,因為它給了 offline_access:
The application will be able to access data until you revoke permission (offline access).
This application is requesting permission to:
Read all data about the selected patient
Read * records
Read our profile information
Search for * records
B 醫院沒有那一句,而且權限逐項列出資源型別:
This application is requesting permission to:
Read all data about the selected patient
Read Patient records
Read Observation records
Search for Patient records
Search for Observation records
* 讓畫面變短,但使用者反而看不出你要讀什麼。day10 講「同意畫面不會替使用者分辨前綴」,兩張畫面並排就是那句話的實際樣子。
授權完成後,兩家拿到的 patient context 是不同的人。console 印出來的 client.patient.id 一個是 018f428e-…,一個是 ab4e7a7d-…。
拿錯 token 的下場
現在我們來做一件不應該做的事:拿 B 醫院的 access token 去打 A 醫院的端點。
預期是回 403 或 401。實際結果是回 200。
同一次跑出來四組對照:

看第二列跟第四列。拿錯 token 打這台,結果跟完全不帶 token 一樣。 都回 200,給你一份只剩部分欄位的精簡版資料。
這比乾脆回 403 危險得多。你的 app 只看狀態碼會以為成功,畫面照樣渲染,只是欄位少了一堆。使用者看到的是一份看起來正常但不完整的病歷。
SUBSETTED 標記是唯一的判斷條件。它在 meta.tag 裡,code 是 SUBSETTED,display 是 Resource encoded in summary mode。day20 用 _elements 也會拿到同一個標記。
所以多伺服器的 app 要檢查 SUBSETTED。 拿到帶這個標記的資源,代表你手上的不是完整資料。不能拿去做臨床判斷,也不該存進快取當成完整版。
這台 Sandbox 不會擋 scope,但是醫院的主機一定會擋
還有一件事。B 的 scope 沒給 Condition,也沒給 MedicationRequest。
但我用 B 的 token 去讀那兩種資源,一樣讀得到。

這跟 day10 講過的「這台在資源端完全不檢查 scope」是同一件事。只是在跨伺服器的情境下更明顯。真實 EHR 會回 403。
實務上的意思是:你在這台 sandbox 上測不出 scope 設錯。程式跑得好好的,換到真醫院就噴 403。要驗證 scope 有沒有設對,要看 token response 回傳的 scope 欄位。不能靠「API 呼叫成功」判斷。
設定要長什麼樣
把上面這些整理成一個結構,每一台一筆:
export const SERVERS = {
a: {
label: 'A 醫院',
fhirBaseUrl: 'https://launch.smarthealthit.org/v/r4/sim/WyIzIiwiMDE4…/fhir',
clientId: 'hospital-a-client',
scope: 'launch/patient patient/*.rs openid fhirUser offline_access',
},
b: {
label: 'B 醫院',
fhirBaseUrl: 'https://launch.smarthealthit.org/v/r4/sim/WyIzIiwiYWI0…/fhir',
clientId: 'hospital-b-client',
scope: 'launch/patient patient/Patient.rs patient/Observation.rs',
},
}
有兩件事這個結構刻意沒放。
沒有 clientSecret。 純前端的 app 都是明碼,藏不住密鑰,這裡只能是 public client。火線超人可以存 secret,因為它有後端而且是加密的。如果你要接的醫院要求 confidential client,那條路一定要走後端。
沒有端點位址。 authorization_endpoint 跟 token_endpoint 不寫死,靠 discovery 問出來,這部分我們 day22 再來說。
跟著做:模擬兩家醫院
程式碼可以接續 day20 結束時的專案。
第一步,在 Launcher 開兩份設定
到 SMART Health IT Launcher。Launch Type 挑 Patient Standalone Launch。
展開進階選項,填入 Client ID、Scopes,並指定一位病人。填完複製 Server's FHIR Base URL 那一整串。
做兩次,兩次填不同的 Client ID、不同的 scope、不同的病人。你會拿到兩串不同的 /sim/ 網址。
第二步,新增 servers.js
把兩組設定寫進去,格式照上面那個結構。fhirBaseUrl 換成你自己複製的那兩串。
第三步,畫面上加兩個按鈕
index.html 把原本那個按鈕換成兩個:
<div id="connect" hidden class="flex gap-2">
<button data-server="a" class="rounded bg-slate-800 px-4 py-2 text-white">連線到 A 醫院</button>
<button data-server="b" class="rounded bg-slate-800 px-4 py-2 text-white">連線到 B 醫院</button>
</div>
app.js 加上 import 與綁定:
import { SERVERS } from './servers.js'
const SERVER_KEY = 'smart-app.server'
function authorize(key) {
const server = SERVERS[key]
sessionStorage.setItem(SERVER_KEY, key)
FHIR.oauth2.authorize({
iss: server.fhirBaseUrl,
clientId: server.clientId,
scope: server.scope,
redirectUri: window.location.pathname,
})
}
按鈕的事件綁定放在 offerConnect() 裡面,也就是還沒授權時才會跑到的那個分支:
for (const button of connectButton.querySelectorAll('[data-server]')) {
button.addEventListener('click', () => authorize(button.dataset.server))
}
sessionStorage 那一行是必要的。授權會離開你的頁面再導回來。回來的時候程式不知道使用者剛才按了哪一家,得自己記住。
authorize() 到 day22 還會再動一次。那時候它會變成 async,因為要先過一層 discovery 快取再送出授權請求。
第四步,畫面上標出是哪一家
showPatient() 裡把記下來的那家醫院讀回來,狀態列與 console 都標上醫院名稱:
const server = SERVERS[sessionStorage.getItem(SERVER_KEY) ?? 'a']
status.textContent = summary.name
? `${server.label}:${summary.name} 的基本資料`
: `${server.label}:這位病人沒有登記姓名`
console.log('伺服器:', server.label, server.clientId)
不標的話兩家授權完的畫面長得一模一樣,你會分不出現在看的是誰的資料。
姓名那個三元判斷是 day15 那一課的延續。Patient 的欄位幾乎都是選填,summary.name 拿不到東西是常態。直接塞進樣板字串,畫面會出現「undefined 的基本資料」。
第五步,兩家各授權一次
按 A 醫院,走完流程,看畫面上顯示的病人是誰。然後執行 sessionStorage.clear() 並重整,改按 B 醫院再走一次。
兩次跑完有三個地方不一樣。一是病人,二是同意畫面,三是 console 印出的 scope。

第六步,故意拿錯 token
授權完 B 醫院之後,在 console 裡拿 B 的 token 去打 A 的端點。兩個常數換成你自己第一步複製的那一串與你挑的病人,不要抄我的:
const A_BASE_URL = 'https://launch.smarthealthit.org/v/r4/sim/WyIzIiwiMDE4…/fhir'
const A_PATIENT_ID = '018f428e-34f6-4707-8009-5ad742f901e7'
const client = await FHIR.oauth2.ready()
const response = await fetch(`${A_BASE_URL}/Patient/${A_PATIENT_ID}`, {
headers: { Authorization: `Bearer ${client.state.tokenResponse.access_token}` },
})
const body = await response.json()
console.log(response.status, body.meta?.tag)
你會看到 200,然後在 meta.tag 裡看到 SUBSETTED。
把 Authorization 那一行整個拿掉再跑一次,結果一模一樣。這就是這一篇最該記住的畫面。
完整可跑的版本在 GitHub 上的 day22-multi-server,想先看跑起來的樣子可以直接開線上版。day21 跟 day22 共用那一份,你要改的是 SERVERS.a 與 SERVERS.b 裡的 fhirBaseUrl、clientId 與 scope。
小結
每一家醫院是獨立的一組:註冊、credentials、scope、端點。共用一組設定在正式環境會被擋下來,可能擋在授權、換 token 或讀資源那一關。這台 sandbox 不擋,它回 200 加一份標著 SUBSETTED 的殘缺資料,那才是最難查的狀況。
明天我們來處理端點。每次授權都去問一次 .well-known 是浪費,但快取下來又會遇到「伺服器改版了怎麼辦」。明天那篇也是第三幕的最後一篇。