iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

本文同步發表於個人部落格:身分識別與 fhirUser


再看一次火線超人那份預設清單的最後兩行:

launch/patient
openid
fhirUser

launch/patient 我們在 day11 拆過了,它換回 patient 欄位。剩下這兩個換回的是 id_token,而 id_token 回答的是一個 patient 欄位回答不了的問題。

差別在這裡:patient 說的是「這次要看的是誰的資料」,fhirUser 說的是「現在操作的是誰」。

在火線超人身上這兩個剛好是同一個人,使用者是病人本人,他看的是自己的資料。但這是巧合,不是通則。醫師在診間開你的 app,操作者是醫師而資料是病人的,兩者完全不同。

如果你的程式拿 fhirUser 當成要顯示的那位病人,在診間就會去查醫師那筆資源。 畫面上出現的是醫師自己,不是他正在看的病人。

id_token 就是三段 base64

id_token 是一個 JWT,用兩個點分成三段。拿病人身分在這台跑的那一趟 id_token 來看,三段的長度是:

36 . 500 . 342
└┬┘  └┬─┘ └┬─┘
header payload signature

加上兩個點,整串 880 個字元。中間那段最長也最會變,claims 都在裡面。換成醫師身分再跑,這一段變成 384,整串是 764。頭尾兩段這兩趟都沒變。header 逐字相同,簽章長度由這台用的演算法與金鑰長度決定。

第一段解開是這樣:

{"alg":"RS256","typ":"JWT"}

第二段才是內容,也就是 claims。第三段是簽章。

JWT 沒有加密,只是編碼。 任何人拿到這串字都能解開讀內容,base64url 而已。所以 id_token 裡不會有密碼,也不該有敏感資料。id_token 的用途是「讓拿到的人相信裡面寫的那幾個 claim」。可信度靠的是第三段那個簽章,不是靠內容看不懂。

day11 那條界線在這裡就用上了。access_token 對你的 app 是不透明字串,id_token 是給你讀的。前者你不該解,後者你該解。

claims 逐個看

在這台 Launcher 上實跑拿到的七個 claim:

claim 用途
fhirUser Patient/d48ac962-… 操作者對應的 FHIR 資源位址
profile fhirUser 舊的欄位名,內容一樣
aud my-smart-app 這張 token 發給誰,等於你的 client id
iss 那台的 FHIR base URL 誰發的
sub 64 字元的 hash 使用者在這台的固定識別碼
iat / exp 相差 3600 秒 發出與過期時間

id_token 解剖圖,標題「解得開,不代表可信」,副標「JWT 只是 base64,aud 要自己檢查」。上方一橫排三個色塊代表 JWT 三段,中間以小圓點分隔:左邊灰藍色塊寫著 36 與 header,中間最寬的深藍色塊寫著 500 與「payload,也就是 claims」,右邊灰藍色塊寫著 342 與 signature,三個色塊的寬度依這三個數字配置。色塊列右下方一行灰色小字標明「patient-standalone 那一趟,全長 880」。中段是一張白色卡片列出六列 claim:fhirUser 值為 Patient 斜線 d48ac962 開頭或 Practitioner 斜線 4826865,右側有一個深藍色標籤寫「操作者是誰」;profile 註明同上、是 OIDC 的舊欄位名;aud 值為 my-smart-app,右側有一個珊瑚色標籤寫「你必須自己驗」;iss 為該 sim 路徑的 FHIR 位址;sub 為 b9c32517 開頭的 64 字元,註明要記住使用者請用這個;iat 與 exp 相差 3600 秒。最下方三個深藍色方塊並排:第一塊「三段都不是加密」,說 atob 一下就讀得到,任何拿到這串字的人都一樣;第二塊「可信度來自第三段」,說授權碼流程裡因為是自己跟 token endpoint 直接拿的,OIDC 允許用 TLS 取代驗簽;第三塊標題以珊瑚色寫「但 aud 沒有人替你檢查」,說它必須等於你的 client id,這條擋的是別人家的 id_token

fhirUserprofile 同值不是巧合。profile 是 SMART 早期用的欄位名,規範已經標為 deprecated 並改用 fhirUser。有些伺服器為了相容繼續兩個都回,這台就是。新程式碼讀 fhirUser 就好。

sub這台伺服器給這個使用者的固定識別碼,同一個人每次登入都是同一串。如果你要在自己的資料庫裡記「這個使用者上次看到哪裡」,該當主鍵的是 sub,不是 fhirUser。因為 fhirUser 指向的是一筆 FHIR 資源,而資源是有可能被合併、被換 id 的。

該驗什麼,不該驗什麼

拿到 id_token 之後要做的檢查,OpenID Connect 規範列得很清楚。issaudexpiat 都要驗。

aud 是最該記得的那一個。上面必須有你的 client id。 這條擋的是「別人家的 id_token 被拿來餵給你的 app」。你如果不檢查,別人在另一個 app 登入拿到的 id_token 也能送進來。你的 app 會照著上面的 fhirUser 認人,那個人不是你的使用者。

這種事在有自己後端的 app 上最常見。前端把 id_token 送給後端當登入憑證,後端不驗 aud 就認人。

這台的 aud 是單一字串,直接比對就行。規範允許它是一個陣列,那時候要看的是陣列裡有沒有你的 client id。

簽章那一段反而有個例外:

If the ID Token is received via direct communication between the Client and the Token Endpoint (which it is in this flow), the TLS server validation MAY be used to validate the issuer in place of checking the token signature.

意思是,我們是自己 POST 到 token endpoint 的。TLS 就是網址開頭那個 https 的加密連線。這張 token 透過 TLS 直接收到,中間沒有別人碰過。所以可以用 TLS 的伺服器驗證取代簽章驗證。

這個例外只在授權碼流程成立。 如果 id_token 是從別的地方轉手拿到的,那就必須乖乖驗簽。比如從網址列、從別的服務傳過來的,都要拿 discovery 裡那個 jwks_uri 的公鑰去驗。day06 抓的那份 discovery 裡就有這個欄位。jwks_uri 一直都在,到今天才知道它是給誰用的。我們走的授權碼流程剛好用不上。

fhirUser 只是位址,不是人

fhirUser 的值長這樣:

Patient/d48ac962-78c6-46cf-ba33-a24771bfa0e4

跟 day12 的 patient 欄位一樣,這是一個參照,不是資料。要拿到姓名還是得再查一次。

差別是這次的路徑寫在值裡面了,資源型別/id 兩段都有,直接接在 base URL 後面就能查。

而它指向哪一種資源,取決於登入的是誰:

登入的人 fhirUser
病人本人(patient standalone) Patient/<id>
醫護人員(provider standalone 或 EHR Launch) Practitioner/<id>

同一份程式碼會拿到兩種資源型別。所以不要寫死 Patient/。有兩種寫法:一是用 / 切開,前半是資源型別,後半是 id;二是整串不動,直接接在 base URL 後面查。

還有一件事:查得到與否要看 scope。病人本人那條路,patient/Patient.r 就夠了,因為 fhirUser 指的就是 context 裡那位病人。醫師那條路要 user/Practitioner.r,因為 Practitioner 不在病人 context 裡面。

跟著做:把 id 換成一句問候

起點:day12 之後的 smart-appapp.js 已經改用 fhirclient,畫面顯示病人姓名。

產出:畫面多一行「操作者是誰」。而且你會親手解開一次 JWT,看到裡面到底放了什麼。第四步為了換身分動了兩處,FHIR_BASE_URLSCOPE 都改過。day14 會把這兩處換回病人身分那一版。

第一步,確認 scope 有那兩個

app.jsSCOPE 常數要有 openidfhirUser

const SCOPE = 'launch/patient patient/Patient.r openid fhirUser'

day12 那份就已經有了,確認一下就好。

少了任何一個,fhirclient 會直接給你一句話:

You are trying to get the id_token but you are not using the right scopes.
Please add 'openid' and 'fhirUser' or 'profile' to the scopes you are requesting.

這句是函式庫自己印的。day11 說過這台 Launcher 要兩個一起送才發 id_token,規範沒有這樣要求。fhirclient 內部也在檢查同一件事。

第二步,手動解一次

showPatient() 裡加這幾行。先手動解,等一下再看函式庫版:

const raw = client.state.tokenResponse.id_token
const [header, payload] = raw.split('.').slice(0, 2)
const decode = (part) => JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')))

console.log('header:', decode(header))
console.log('claims:', decode(payload))

atob 是瀏覽器內建的 base64 解碼。中間那兩個 replace 是因為 JWT 用的是 base64url,它把 +/ 換成了 -_,要換回來 atob 才吃。

跑一次,console 會出現:

header: { alg: 'RS256', typ: 'JWT' }
claims: { profile: 'Patient/d48ac962-…', fhirUser: 'Patient/d48ac962-…',
          aud: 'my-smart-app', sub: 'b9c32517…', iss: 'https://…/fhir',
          iat: 1786120245, exp: 1786123845 }

你剛剛沒有經過任何驗證就讀到了內容。 這就是前面說的「JWT 只是編碼」,實際感受一下比讀十遍定義有用。

第三步,換成函式庫版並取回資源

上面那段留著當註解,實際用這個:

const idToken = client.getIdToken()
console.log('fhirUser:', idToken.fhirUser)
console.log('aud 是不是我:', idToken.aud === CLIENT_ID)

const user = await client.user.read()
const userName = user.name?.[0]
const who = userName ? `${userName.given?.join(' ')} ${userName.family}` : user.id

result.textContent += `(操作者:${user.resourceType} ${who})`

client.getIdToken() 回傳的是已經解碼好的 claims 物件,不用自己 split。client.user.read() 則幫你把 fhirUser 那個參照拿去查回來。

那行 aud === CLIENT_ID 是前面說的檢查。fhirclient 不會替你做這件事,這一行得自己寫。

第四步,換一個身分再跑

回到 Launcher。Launch Type 改成 Provider Standalone Launch。複製新的 Base URL 換掉 FHIR_BASE_URLSCOPE 裡的 patient/Patient.r 改成 user/Practitioner.r,重跑。

fhirUser: Practitioner/4826865
(操作者:Practitioner Rahul Kumar Sharma)

你從 Launcher 挑到的醫師,id 與姓名都會跟這裡不一樣。

下面這張圖是我另外跑的一趟,那一趟連 launch/patient 都沒送。所以圖上的 scope 只有三項,token response 裡也沒有 patient 欄位。

執行結果圖,淺灰綠底,標題「換一個身分,fhirUser 就換一種資源」,副標「provider-standalone 重跑,id_token 的 claims 共七項」。中央一個白色圓角卡片。卡片最上一列是三個灰色小圓點,右邊一行灰字寫著腳本直接打端點,全程不經瀏覽器,所以這個版型沒有網址列。第二列白底左邊是灰字「這次送的 scope」,右邊以等寬粗體標出 openid fhirUser user/*.rs。第三列淺灰底是 Console 標籤。內容區兩行等寬字輸出:第一行 fhirUser 冒號,值為 Practitioner 斜線 4826865,整行以淡粉紅底加左側珊瑚色直條標示,欄位名與值都是珊瑚色粗體;第二行 profile 冒號,值同樣是 Practitioner 斜線 4826865,其中 Practitioner 以深綠色粗體標示,後面接一行灰字註記「與 fhirUser 同值」。卡片最下一列淺灰底左邊寫著 id_token claims,右邊並排七個膠囊標記,依序為 aud、exp、fhirUser、iat、iss、profile、sub,其中 fhirUser 與 profile 兩個是深綠底白字,其餘五個是淺灰底灰字。卡片下方三條綠色圓形編號註記:一,珊瑚色那一行是差別所在,同一組 scope 在 patient-standalone 下指向 Patient 斜線 id;二,七項 claim 裡只有深色那兩個是同一個值;三,沒送 launch/patient,所以這次的 patient 欄位不存在

預期結果:手動解出來的 claims 與 getIdToken() 的內容一致;病人身分登入時 fhirUser 指向 Patient。醫護身分登入時指向 Practitioner,畫面上那行問候跟著換人。

id_token 不是通行證

最後一件事,講清楚免得踩坑。

id_token 是一張身分聲明,不是授權憑證。它只回答「這個人是誰」,不回答「他能做什麼」。

實務上最常見的錯誤是拿它當 API 的 token 送出去:

// 錯的
headers: { Authorization: `Bearer ${idToken}` }

FHIR 伺服器要的是 access_token。兩張都是 JWT、長得很像、放在同一個 response 裡,但用途完全不同。送錯了通常會拿到 401,而你會盯著那串看起來很正常的 token 想很久。

反過來也一樣:不要為了知道使用者是誰去解 access_token。這台的 access token 剛好是 JWT 解得開,那是實作細節。換一台可能就是一串你解不開的字串。要身分就用 id_token,這是它存在的理由。

小結

id_token 回答的是「操作者是誰」,跟 patient 欄位回答的「這次看誰的資料」是兩個問題。在病人自用的 app 上兩者剛好同一人,換到診間場景就分開了。

JWT 只是 base64 編碼,不是加密,atob 一下就讀得到內容。JWT 的可信度來自簽章。授權碼流程裡我們是自己跟 token endpoint 直接拿的。OIDC 因此允許用 TLS 取代驗簽。但 aud 一定要自己檢查,那是擋別人家 token 的那道門。

fhirUser 是位址不是人。指向 Patient 還是 Practitioner 由登入的身分決定。程式不要寫死型別。要記住使用者請用 sub

到今天為止,這個 app 該有的東西都有了:端點、授權、token、病人、身分。剩下最後一個問題,那張 access token 一小時後就過期了。

明天講 access token 過期之後怎麼換一張新的。順便把整個第二幕收成一份可以直接貼的完整檔案。


上一篇
Day12 - 解析 Launch Context
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言