下拉重新整理

SMART Discovery 與能力探索

2,898 字 8 分鐘閱讀 12 次閱讀

火線超人的 Fhir::OAuth2Service 規格裡有一句話讀起來很兇:

The service SHALL NOT fetch FHIR server metadata or fall back to guessed URL patterns.

不准自己抓 metadata,也不准猜網址。規格會特地寫「不准猜」,通常代表這件事曾經發生過。

猜其實很好猜。你看過一台伺服器長這樣:

FHIR base    https://某醫院/fhir
authorize    https://某醫院/auth/authorize
token        https://某醫院/auth/token

規律太明顯了,把 /fhir 換成 /auth/authorize 就好。接第二家、第三家,前幾次都對,直到有一家不是。

所以火線超人後來把端點存進資料庫,FhirConfiguration 上多了 authorize_endpointtoken_endpoint 兩個欄位,取用時走一套四層的順序:資料庫有值就直接用;沒值但這台有啟用 OAuth,就去問一次再存起來;再不然用內建的備援設定;四層都沒有就拋錯,不猜。

今天要講的就是第二層那個「去問一次」。

端點是問出來的

standalone launch 是 app 自己啟動的,你手上只有一個 FHIR base URL。授權網址在哪、token 要往哪換,全都得自己弄清楚。

SMART 的作法是在固定的位置放一份公告,路徑是 base URL 後面接 /.well-known/smart-configuration。這是 well-known URI 的慣例,跟 /.well-known/security.txt/.well-known/openid-configuration 是同一套規矩:把「關於這台伺服器的說明」放在一個大家都知道的位置。

抓一次看看:

curl https://launch.smarthealthit.org/v/r4/fhir/.well-known/smart-configuration

回來的是一份 JSON,1.5 KB 不到,我把當天實抓的內容整理成表:

欄位 用途
issuer .../v/r4/fhir 這台伺服器的身分識別
authorization_endpoint .../v/r4/auth/authorize 把使用者送去這裡要授權
token_endpoint .../v/r4/auth/token 拿 code 來這裡換 token
introspection_endpoint .../v/r4/auth/introspect 查一張 token 還有效嗎
jwks_uri .../keys 驗證簽章用的公鑰
grant_types_supported authorization_codeclient_credentials 支援哪幾種拿 token 的方式
token_endpoint_auth_methods_supported client_secret_basicclient_secret_postprivate_key_jwt 換 token 時 client 可以怎麼證明身分
code_challenge_methods_supported S256 PKCE 只收這一種,day08 會用到
scopes_supported openidfhirUserlaunch/patientpatient/*.* 可以申請哪些權限,day10 會用到
response_types_supported codetokenid_token 授權回應的形式
capabilities 18 項,見下 這台支援 SMART 的哪些能力

前兩個是今天的主角。authorization_endpointtoken_endpoint 一旦拿到,day07 到 day09 的路就通了。

token_endpoint_auth_methods_supported 那一列我們用不到,列出來是為了讓表格跟實際回應一致。它講的是 client 換 token 時可以怎麼證明身分,三種方式都要有一個祕密或一把私鑰。純前端的 app 兩樣都藏不住,所以我們什麼都不送,day07 會說明為什麼這樣還能成立。

capabilities 是能力清單,不是設定

capabilities 是一個字串陣列,這台 sandbox 列了 18 項。挑幾個看得懂的:

launch-ehr                     支援 EHR launch
launch-standalone              支援 standalone launch
client-public                  支援沒有 client secret 的公開 client
sso-openid-connect             支援用 OpenID Connect 認身分
context-standalone-patient     standalone 模式下可以帶病人 context
permission-patient             支援 patient/ 前綴的 scope
permission-offline             支援 offline_access,也就是 refresh token

launch-ehrlaunch-standalone 同時在清單裡,代表 day05 講的兩種模式這台都收。client-public 對我們特別重要:純前端的 app 沒有地方藏 client secret,只能當 public client,這台支援才走得下去。permission-offline 則決定 day14 有沒有 refresh token 可以玩。

要注意它講的是這台伺服器支援什麼,不是你的 app 要什麼。你想要的東西不在清單裡,那就是走不通,得換方案或換伺服器,不是把參數硬送出去試。

為什麼不能自己拼端點

前面說猜網址前幾次都會對,來看它什麼時候不對。

day05 的跟著做裡,Launcher 的 standalone 模式給了一串很長的 Base URL,中間夾著 /sim/ 和一堆編碼。現在把 discovery 打在那串長網址上:

curl https://launch.smarthealthit.org/v/r4/sim/WzMsIiIsIiIsIkFVVE8i…/fhir/.well-known/smart-configuration

回來的端點是這樣:

authorization_endpoint
  https://launch.smarthealthit.org/v/r4/sim/WzMsIiIsIiIsIkFVVE8i…/auth/authorize

token_endpoint
  https://launch.smarthealthit.org/v/r4/sim/WzMsIiIsIiIsIkFVVE8i…/auth/token

端點也帶著那段 /sim/ 編碼。 如果你照前面那個「把 /fhir 換成 /auth/authorize」的規律自己拼,會拼出乾淨的 /v/r4/auth/authorize,那是另一組端點,模擬設定全掉了。授權伺服器會把你退回來,錯誤訊息是 Invalid launch options,而你會盯著那個網址看很久,因為它看起來完全正常。

兩種取得授權端點方式的對照圖,標題「discovery 是伺服器給的地圖」,副標「探索出來才是對的,自己拼的會出錯」。最上方深藍色橫條標著「起點」,內容是 base URL https://launch.smarthealthit.org/v/r4/ 之後接珊瑚色的 sim/WzMsIiIsIiIsIkFVVE8i… 再接 /fhir。下方左右兩張白色卡片:左卡標題「自己拼」,做法是把 /fhir 換成 /auth/authorize,結果為 …/v/r4/auth/authorize,底下用刪除線標示 sim/WzMs… 掉了,結論是叉號加 Invalid launch options;右卡標題「問 discovery」,做法是 GET {base}/.well-known/smart-configuration,結果為 …/v/r4/ 接珊瑚色 sim/WzMs…/ 再接 auth/authorize,結論是勾號加同意畫面出現。最下方深藍色區塊把兩串網址上下對齊,自己拼那行在 /v/r4/ 與 /auth/authorize 之間是一段珊瑚色虛線代表空缺,問出來那行同一位置是珊瑚色的 sim/WzMsIiIsIiIs…/,兩行的 /auth/authorize 垂直對齊;區塊底部寫著兩串都是合法網址,錯的那條看不出哪裡錯

最要命的是這種錯不會在你拼網址的當下爆出來,要等到使用者被送去授權、對方退回來,你才會看到那個訊息。

這就是「端點是問出來的,不是算出來的」。你的程式碼裡不該出現任何一行在組授權網址,只該有一行在讀 discovery 回來的值。

兩種問法,火線超人走的是舊的那條

SMART 其實有兩個地方可以問到端點。

.well-known/smart-configuration 是 SMART App Launch 2.0 的作法,也是現在的建議。另一條路更早,是從 FHIR 的能力宣告 /metadata 裡挖:CapabilityStatement 的 rest[0].security 底下掛著一個擴充,url 是 http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris,裡面才是 authorizetoken

火線超人的 discover_oauth_endpoints! 走的就是這條,它抓 /metadata,從 oauth-uris 擴充取值。這條路現在仍然能用,同一台 sandbox 兩種問法拿到的端點一模一樣,我實測過。

但這兩條路的成本差很多:

.well-known/smart-configuration /metadataoauth-uris
回應大小 1,488 bytes 942,186 bytes
內容 11 個欄位 146 種資源的完整能力宣告
拿到的東西 端點加能力清單 只有三個 URL

為了兩個網址下載 920 KB,因為 CapabilityStatement 要把這台伺服器支援的每一種資源、每一個搜尋參數都列出來。新專案沒有理由走這條,但如果你接的是舊系統,對方可能只有 /metadata,這條路要知道它在。

妥當的寫法是先問 .well-known,404 就退回去挖 /metadata。火線超人沒有做這層退路,因為它接的伺服器都吃 /metadata,這也是規格裡只寫一種方法的原因。

跟著做:問出端點並存下來

起點:day04 建好的 smart-app,裡面有 index.htmlapp.jsvendor/fhir-client.pure.min.js

產出:多一個 discovery.jsapp.js 改成呼叫它,畫面與 console 印出這台伺服器的授權端點與 token 端點。

第一步,換掉伺服器位址

day04 用的是 https://r4.smarthealthit.org,那是一台開放讀取、不需要授權的伺服器。它讓我們把環境跑通,但接下來要練授權,得換一台真的會擋你的。

SMART Health IT Launcher,Launch Type 選 Patient Standalone Launch,複製 Server's FHIR Base URL 欄位那一整串(就是含 /sim/ 的那個),然後改 app.js 頂端那一行:

// day04 的值:'https://r4.smarthealthit.org'
export const FHIR_BASE_URL =
  'https://launch.smarthealthit.org/v/r4/sim/WzMsIiIsIiIsIkFVVE8i…/fhir'

那串編碼每個人不一樣,一定要從自己的 Launcher 畫面複製,不要抄我的。這一步沒換,後面 day07 到 day09 會一路錯下去,而且錯得很難查。

第二步,寫 discovery.js

新增一個檔案,它只做一件事:

export async function discoverEndpoints(fhirBaseUrl) {
  const url = `${fhirBaseUrl}/.well-known/smart-configuration`
  const response = await fetch(url)

  if (!response.ok) {
    throw new Error(`discovery 回了 HTTP ${response.status}`)
  }

  const config = await response.json()

  if (!config.authorization_endpoint || !config.token_endpoint) {
    throw new Error('這台伺服器沒有提供 OAuth 端點')
  }

  return {
    authorize: config.authorization_endpoint,
    token: config.token_endpoint,
    pkceMethods: config.code_challenge_methods_supported ?? [],
    capabilities: config.capabilities ?? [],
  }
}

兩個檢查值得說一下。response.ok 擋的是 404,也就是這台根本沒放 well-known;後面那個檢查擋的是「有回應但沒有端點」,多半是你抓到了一台不支援 OAuth 的伺服器。兩種情況都拋錯,不要讓 undefined 流到下一步去,不然你會在 day07 拿到一個 https://undefined 的授權網址。

第三步,改 app.js

import { discoverEndpoints } from './discovery.js'

export const FHIR_BASE_URL =
  'https://launch.smarthealthit.org/v/r4/sim/WzMsIiIsIiIsIkFVVE8i…/fhir'

const result = document.querySelector('#app')

discoverEndpoints(FHIR_BASE_URL)
  .then((endpoints) => {
    console.log('authorize:', endpoints.authorize)
    console.log('token:', endpoints.token)
    console.log('PKCE 方法:', endpoints.pkceMethods)
    console.log('capabilities:', endpoints.capabilities)
    result.textContent = `找到授權端點,共 ${endpoints.capabilities.length} 項能力`
  })
  .catch((error) => {
    result.textContent = `discovery 失敗:${error.message}`
  })

day04 那段用 FHIR.client() 查 Patient 的程式碼可以刪掉了,它的任務是驗證環境,已經完成。vendor/ 裡的檔案先留著,day12 會用回來。

第四步,跑起來看

啟動靜態伺服器,兩種擇一,然後開瀏覽器進去按 F12 看 console。

習慣命令列的話,在 smart-app 目錄執行:

python3 -m http.server 5173

用 VS Code 的話,對 index.html 按右鍵選 Open with Live Server,瀏覽器會自己開起來,預設埠號是 5500。

兩者埠號不一樣沒關係,今天沒有人在檢查它。等 day07 要去授權伺服器註冊 redirect URI,那個網址一個字都不能差,到時候挑一個固定下來就好。

預期結果:畫面與 console 長這樣,authorize 與 token 都夾著跟你 base URL 相同的那段 /sim/ 編碼。這代表端點是問出來的,不是你拼的。

瀏覽器視窗的示意畫面,標題「discovery 跑對了的樣子」,副標「authorize 與 token 都帶著你 base URL 的那段 sim 編碼」。畫面上有三個綠色圓形編號,分別掛在網址列、Console 第一行、最下面那行 404 的左側,與圖片下方的三條註記一一對應。網址列顯示 http://localhost:5173,右上角是編號 1。頁面內容是一塊淺綠底、左緣有深綠直線的區塊,寫著「找到授權端點,共 18 項能力」。下方 Console 面板左緣是編號 2,依序列出四行輸出:authorize 是 https://launch.smarthealthit.org/v/r4/ 接一段珊瑚色的 sim/WzMsIiIsIiIs… 再接 /auth/authorize;token 同樣夾著那段珊瑚色編碼,結尾換成 /auth/token;PKCE 方法是 S256;capabilities 是 18 項,開頭依序為 launch-ehr、launch-standalone、client-public。四行之下隔一條虛線,一行灰化的 GET http://localhost:5173/favicon.ico 404 File not found,左緣是編號 3。圖片下方三條編號註記:埠號是 5173,用 Live Server 的話會是 5500,兩個都對;珊瑚色那段是你 base URL 帶的 sim 編碼,自己拼網址拼不出來;最下面那行 404 是瀏覽器自己去找網站圖示,跟你的程式無關,忽略

如果畫面顯示 discovery 回了 HTTP 404,多半是 base URL 尾巴多了一個斜線,變成 …/fhir//.well-known/…。把尾斜線去掉再試一次。

小結

今天做的事情用一句話講完:把 base URL 交出去,換回一份端點清單。

這件事看起來只是多打一支 API,但它決定了你的 app 能不能接第二家醫院。端點寫死或用規律推算,前幾家都會過,然後在某一家掛掉,而且掛掉的樣子通常不明顯,就像那串 /sim/ 一樣,錯誤的網址看起來完全正常。

火線超人繞了一圈才把這件事收斂成一個方法加兩個資料庫欄位,順序是:先問,問到就存起來,之後直接用存的。你的 app 現在還沒有資料庫可以存,但至少已經知道要問誰。

端點到手,接下來就是把使用者送去 authorization_endpoint 那個網址。可是送過去之前要先掛上一串參數,每一個都有它擋掉的東西。

明天圖解授權碼流程,把那串參數一個一個拆開看。