iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

本文同步發表於個人部落格:一組設定打天下行不通


火線超人的資料庫裡有一張資料表,每一台 FHIR 伺服器都紀錄一筆。

每一筆紀錄裡面有三個跟授權有關的欄位:client_typeclient_idclient_secret,最後那個是加密存的。client_type 只有兩種值,publicconfidential_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.rspatient/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。那是註冊時談好的,也就是上面那張表兩家不同的那一列。

兩台伺服器端點對照圖,米色底,標題「三個端點欄位,沒有一個相同」,副標「兩份模擬設定各自問一次 .well-known/smart-configuration,三個端點欄位讀出來沒有一個相同」。畫面中央左右並排兩張深藍色大卡片。左卡片左上角白色粗體寫 A 醫院,右上角一顆淺藍色膠囊標記寫 .well-known 回 200。卡片內第一區塊灰色小標寫 FHIR BASE URL,底下一塊更深的深藍色方塊,以等寬字寫著 https://launch.smarthealthit.org/v/r4/sim/WyIzIiwi 接珊瑚色粗體的 MDE4ZjQyOGUt 再接刪節號與 /fhir,珊瑚色那一段就是兩台不同的地方。往下三個區塊之間各有一條細分隔線:第一個灰色等寬字寫 issuer,底下白色粗體寫「就是上面這一串」;第二個寫 authorization_endpoint,底下寫「帶 A 自己的 sim 編碼」,其中 A 是珊瑚色;第三個寫 token_endpoint,底下同樣寫「帶 A 自己的 sim 編碼」。右卡片結構與左卡片完全相同,標題是 B 醫院,base URL 的珊瑚色那一段是 YWI0ZTdhN2Qt,三個端點欄位的值依序是「就是上面這一串」「帶 B 自己的 sim 編碼」「帶 B 自己的 sim 編碼」。兩張卡片下方是一條白底、左緣有一條珊瑚色直條的橫帶,左半邊第一行粗體寫「兩組完全獨立的端點」,第二行寫「三個欄位逐個比對,A 與 B 沒有一個對得起來」;右半邊上一行灰字寫「共用的端點欄位」,下一行珊瑚色粗體寫「零個」。圖片最下方一行灰字:兩串 base URL 差在 sim 那一段,前面的 /v/r4/sim/ 與後面的 /fhir 完全一樣

先說一下開兩份設定的限制。 兩份模擬設定背後是同一台伺服器。所以測不出真實跨醫院的差異。一是 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

同一次跑出來四組對照:

四組請求的對照圖,米色底,標題「四組請求,兩組結果一模一樣」,副標「B 醫院授權完之後,同一次實跑打出去的四個請求」。畫面主體是一張表,最左邊一欄是圓形列號 1 到 4,其中 2 與 4 是珊瑚色底白字,1 與 3 是米色底灰字。表格最上方一排深藍色底白字的欄標題,由左而右依序是請求、狀態碼、Content-Type、結果。第一列白底加淺灰外框,請求是 B token 讀 B base 的 B 病人,狀態碼 200,Content-Type 是 application/fhir+json,結果是完整資源。第二列整列珊瑚色底白字,請求是 B token 讀 A base 的 A 病人,狀態碼 200,Content-Type 是 application/fhir+json,結果是等寬字的 SUBSETTED。第三列白底,請求是亂寫的 token 讀 A base,狀態碼 401,Content-Type 是 text/plain,結果是等寬字的 jwt malformed。第四列整列珊瑚色底白字,請求是完全不帶 token 讀 A base,狀態碼 200,Content-Type 是 application/fhir+json,結果是 SUBSETTED,四個欄位與第二列逐格相同。表格下方一整條深藍色橫幅,左半邊第一行白色粗體寫「第二列與第四列,四個欄位逐格相同」,第二行寫「拿錯 token 的待遇,等於完全沒帶 token,回的都是 summary 模式的資料」,其中「完全沒帶 token」以珊瑚色標示;右半邊上一行灰字寫「app 只看狀態碼」,下一行白色粗體寫「兩列都判定成功」。圖片最下方一行灰字:第三列是四列裡唯一乾脆失敗的一列,body 是 text/plain 的 Invalid token: jwt malformed,不是 FHIR 資源

看第二列跟第四列。拿錯 token 打這台,結果跟完全不帶 token 一樣。 都回 200,給你一份只剩部分欄位的精簡版資料。

這比乾脆回 403 危險得多。你的 app 只看狀態碼會以為成功,畫面照樣渲染,只是欄位少了一堆。使用者看到的是一份看起來正常但不完整的病歷。

SUBSETTED 標記是唯一的判斷條件。它在 meta.tag 裡,codeSUBSETTEDdisplayResource encoded in summary mode。day20 用 _elements 也會拿到同一個標記。

所以多伺服器的 app 要檢查 SUBSETTED 拿到帶這個標記的資源,代表你手上的不是完整資料。不能拿去做臨床判斷,也不該存進快取當成完整版。

這台 Sandbox 不會擋 scope,但是醫院的主機一定會擋

還有一件事。B 的 scope 沒給 Condition,也沒給 MedicationRequest。

但我用 B 的 token 去讀那兩種資源,一樣讀得到

scope 與實際讀到的資源對照圖,米色底,標題「scope 給兩種,四種都讀得到」,副標「B 醫院授權完,用同一張 token 讀四種資源」。畫面最上方一條深藍色橫帶,左邊兩行灰字寫「B 醫院這張 token 拿到的 scope」,右邊白色等寬粗體寫 launch/patient patient/Patient.rs patient/Observation.rs。往下一排米色欄標題,由左而右是資源型別、在 scope 裡、這次實際讀回來的。再往下四列白底卡片。第一列左緣有一條深藍色直條,資源型別是等寬字的 Patient,在 scope 裡那格寫「有」,實際讀回來的是 Renea Quigley,生日 1957-12-26。第二列左緣同樣是深藍色直條,資源型別是 Observation,在 scope 裡寫「有」,實際讀回來的是收縮壓 11 筆落在 146 至 188,體重 11 筆都是 80。第三列左緣是珊瑚色直條,資源型別是 Condition,在 scope 裡那格是珊瑚色的「沒有」,實際讀回來的是等寬字 Diabetic renal disease 加上括號裡的 disorder,接灰字「等五筆以上,兩筆 active」。第四列左緣也是珊瑚色直條,資源型別是 MedicationRequest,在 scope 裡是珊瑚色的「沒有」,實際讀回來的是等寬字 insulin human, isophane 70 UNT/ML 接刪節號,再接灰字「一筆 active,開立於 2021-03-11」。表格下方一條白底、左緣有珊瑚色直條的橫帶,左半邊第一行粗體寫「後面兩列不在 scope 裡,這台照樣把資料給你」,第二行寫「真實 EHR 會在資源端把第三、四列擋下來」;右半邊上一行灰字寫「被擋下來的」,下一行珊瑚色粗體寫「零筆」。圖片最下方一行灰字:這四種資源在 B 醫院這位病人身上都有資料,擋與不擋的差別看得出來

這跟 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_endpointtoken_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,
  })
}

事件綁定放在模組層,只做一次。授權前後是同一組按鈕,換的只是文字:

for (const button of connectButton.querySelectorAll('[data-server]')) {
  button.addEventListener('click', () => authorize(button.dataset.server))
}

兩種狀態各有一個函式負責改文字:

function offerConnect() {
  status.textContent = '還沒授權,挑一家醫院開始'

  for (const button of connectButton.querySelectorAll('[data-server]')) {
    button.hidden = false
    button.textContent = `連線到 ${SERVERS[button.dataset.server].label}`
  }
  connectButton.hidden = false
}

function showSwitchControls(currentKey) {
  for (const button of connectButton.querySelectorAll('[data-server]')) {
    const key = button.dataset.server
    const label = SERVERS[key].label
    button.hidden = false
    button.textContent = key === currentKey ? `重新連 ${label}` : `換到 ${label}`
  }
  connectButton.hidden = false
}

showSwitchControls()showPatient() 開頭呼叫,傳目前這家的 key 進去。

授權完不要把按鈕拿掉。拿掉之後想換一家就只剩下開 console 清 sessionStorage 再重整這條路,而那是繞過自己造成的限制。目前這家那顆也留著,改成「重新連」,day22 驗證 discovery 快取有沒有命中就是靠它。

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 醫院,走完流程,看畫面上顯示的病人是誰。回到首頁之後按「換到 B 醫院」,再走一次。不必清 sessionStorageFHIR.oauth2.authorize() 會用新的 state 覆蓋掉舊的。

兩次跑完有三個地方不一樣。一是病人,二是同意畫面,三是 console 印出的 scope

執行結果圖,淺灰綠底色,標題「按下 B 醫院之後跑出來的樣子」,副標「同意畫面按下 Approve,導回之後畫面第一行與 console 的四行」。畫面中央一個白色圓角面板,最上一列是三個灰色小圓點代表瀏覽器視窗,沒有網址列。面板第一段灰色小標寫「授權時的同意畫面」,底下一個淺灰外框的方塊,裡面六行等寬字:第一行 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。方塊下方一行珊瑚色字,左邊有一條珊瑚色短直條,寫「比 A 醫院少一句 offline 說明」。面板第二段灰色小標寫「導回之後畫面第一行」,底下一個淺灰底方塊,粗體寫 B 醫院:Renea Quigley,生日 1957-12-26,後面接灰字 patient id ab4e7a7d-8b0d-41e1-9ce9-3877d7615aed。再往下是一條淺灰底的 Console 標籤,底下四行輸出,每行左邊是灰色的鍵、右邊是深色的值:第一行伺服器是 B 醫院 hospital-b-client;第二行 patient id 是 ab4e7a7d-8b0d-41e1-9ce9-3877d7615aed;第三行 scope 是 launch/patient 接珊瑚色粗體的 patient/Patient.rs patient/Observation.rs;第四行趨勢圖資料點是 11 個日期,各線是中括號包住的 11、11、11 三個數字。面板下方兩條編號註記:編號 1 寫同意畫面把 Patient 與 Observation 逐項列出來,不是一個星號,因為 B 的 scope 是逐項給的;編號 2 寫少掉的那一句是 offline access 的說明,B 醫院沒有給 offline_access

第六步,故意拿錯 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.aSERVERS.b 裡的 fhirBaseUrlclientIdscope

小結

每一家醫院是獨立的一組:註冊、credentials、scope、端點。共用一組設定在正式環境會被擋下來,可能擋在授權、換 token 或讀資源那一關。這台 sandbox 不擋,它回 200 加一份標著 SUBSETTED 的殘缺資料,那才是最難查的狀況。

明天我們來處理端點。每次授權都去問一次 .well-known 是浪費,但快取下來又會遇到「伺服器改版了怎麼辦」。明天那篇也是第三幕的最後一篇。


上一篇
Day20 - FHIR 搜尋,分頁不要自己算
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言