SMART Discovery 與能力探索
火線超人的 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_endpoint 與 token_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_code、client_credentials |
支援哪幾種拿 token 的方式 |
token_endpoint_auth_methods_supported |
client_secret_basic、client_secret_post、private_key_jwt |
換 token 時 client 可以怎麼證明身分 |
code_challenge_methods_supported |
S256 |
PKCE 只收這一種,day08 會用到 |
scopes_supported |
openid、fhirUser、launch/patient、patient/*.*… |
可以申請哪些權限,day10 會用到 |
response_types_supported |
code、token、id_token… |
授權回應的形式 |
capabilities |
18 項,見下 | 這台支援 SMART 的哪些能力 |
前兩個是今天的主角。authorization_endpoint 和 token_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-ehr 和 launch-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 回來的值。
兩種問法,火線超人走的是舊的那條
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,裡面才是 authorize 與 token。
火線超人的 discover_oauth_endpoints! 走的就是這條,它抓 /metadata,從 oauth-uris 擴充取值。這條路現在仍然能用,同一台 sandbox 兩種問法拿到的端點一模一樣,我實測過。
但這兩條路的成本差很多:
.well-known/smart-configuration |
/metadata 的 oauth-uris |
|
|---|---|---|
| 回應大小 | 1,488 bytes |
942,186 bytes |
| 內容 | 11 個欄位 | 146 種資源的完整能力宣告 |
| 拿到的東西 | 端點加能力清單 | 只有三個 URL |
為了兩個網址下載 920 KB,因為 CapabilityStatement 要把這台伺服器支援的每一種資源、每一個搜尋參數都列出來。新專案沒有理由走這條,但如果你接的是舊系統,對方可能只有 /metadata,這條路要知道它在。
妥當的寫法是先問 .well-known,404 就退回去挖 /metadata。火線超人沒有做這層退路,因為它接的伺服器都吃 /metadata,這也是規格裡只寫一種方法的原因。
跟著做:問出端點並存下來
起點:day04 建好的 smart-app,裡面有 index.html、app.js、vendor/fhir-client.pure.min.js。
產出:多一個 discovery.js,app.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 回了 HTTP 404,多半是 base URL 尾巴多了一個斜線,變成 …/fhir//.well-known/…。把尾斜線去掉再試一次。
小結
今天做的事情用一句話講完:把 base URL 交出去,換回一份端點清單。
這件事看起來只是多打一支 API,但它決定了你的 app 能不能接第二家醫院。端點寫死或用規律推算,前幾家都會過,然後在某一家掛掉,而且掛掉的樣子通常不明顯,就像那串 /sim/ 一樣,錯誤的網址看起來完全正常。
火線超人繞了一圈才把這件事收斂成一個方法加兩個資料庫欄位,順序是:先問,問到就存起來,之後直接用存的。你的 app 現在還沒有資料庫可以存,但至少已經知道要問誰。
端點到手,接下來就是把使用者送去 authorization_endpoint 那個網址。可是送過去之前要先掛上一串參數,每一個都有它擋掉的東西。
明天圖解授權碼流程,把那串參數一個一個拆開看。