Token 的生命週期
day11 說過,火線超人那份預設清單裡沒有 offline_access。
那代表它拿不到 refresh token。access token 一小時到期,使用者就得從頭再授權一次。
今天要做的,正是它沒做的那一件事。而且做之前得先想清楚一個問題。refresh token 是一把比 access token 更值錢的鑰匙。你要把它放在哪裡?
一小時是怎麼來的
這台 Launcher 的每次 token response 都有這個欄位:
expires_in: 3600
單位是秒,一小時。這個數字是授權伺服器決定的,不是標準規定的。有的 EHR 給十五分鐘,有的給八小時。你的程式不能寫死,要讀這個欄位。
規範對這個欄位的用字是 recommended 而不是 required。所以還要防它根本沒出現,那時候只能等到請求被擋才知道過期。
要注意它是「還有幾秒過期」,不是「什麼時候過期」。你得自己算:
const expiresAt = Date.now() + tokenResponse.expires_in * 1000
而且這一行要在收到回應的當下就算,不能等到要用的時候才算。因為那時候已經過了不知道多久。
過期之後拿它去打 FHIR 伺服器,最常見的回應是 401。day09 那個「亂寫的 token」拿到的也是 401。同一個狀態碼蓋掉兩種原因,光看它分不出是過期還是無效。
refresh 那一趟長什麼樣
day12 我們把授權交給了 fhirclient,但今天要把這一趟拆開看。因為它是整個流程裡最容易出安全問題的一段。
我手動探測時送的是這三個欄位,跟 day09 手刻的那個換 token 很像:
POST {token_endpoint}
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token={你手上那張}
&client_id=my-smart-app
這三個欄位不是規範的要求。SMART 只要求 grant_type 與 refresh_token,另外允許帶 scope,client_id 不在清單上。fhirclient 預設也不送它,要另外開一個選項才會加。
為了乾淨地比一次,我把兩趟接在一起跑。先走完授權,拿 code 換到第一張 token。再直接拿那張回應裡的 refresh_token 換第二次。同一串 code 衍生的連續兩趟,比起來才不會混到別次實跑的值。
送出去的欄位,第一趟有五個,第二趟只剩三個。兩趟共有的只有 grant_type 與 client_id。
少掉的那幾個裡,code_verifier 的理由最清楚。PKCE 保護的是 code 那一段交換。授權伺服器要確認的是,拿 code 來換的人是不是當初發起授權的那個人。
第二趟根本沒有 code,憑據換成了 refresh token。沒有 code 就沒有東西要比對,code_verifier 自然不用送。
redirect_uri 是同一個道理。它在第一趟的工作是比對 code 上綁定的那個位址。第二趟沒有 code 要比對,它就跟著消失了。
這件事在 token 本身也留下痕跡。我把兩趟的 access token 都解開看過。第一趟的內容帶著 code_challenge 與 code_challenge_method,第二趟沒有這兩欄,長度也跟著從 669 字元掉到 508。這兩個數字會隨 scope 字串長短變動,你跑出來會是另外兩個數。
能解開是這台 SMART Health IT Launcher 的實作細節。day09 講過了。
回來的東西則幾乎沒有差別。兩趟都是 200,回應的九個欄位名與出現順序一模一樣。expires_in 都是 3600,scope 與 patient 也都原樣。

這兩趟真正的差別不在欄位。第一趟那串 code 是使用者登入、按下同意才換到的,第二趟從頭到尾沒有使用者。refresh 完全不需要使用者在場,這是它的用途,也是它的風險。
還有一個數字先記著。這台發的 refresh token 效期是一年,access token 是一小時。一樣是解開來看的,不是標準規定。一小時對一年,這個落差就是等一下要談儲存位置的原因。
這台 Launcher 不作廢舊的 refresh token
我本來想用最直覺的方法驗這件事。refresh 一次,比對前後兩張 refresh token 的字串。
這個方法會騙人。
回來的 refresh_token 字串每次都不一樣。但把它解開看 payload,context、scope、user 三個欄位完全相同。只有 iat 與 exp 各往後移,移的秒數剛好等於兩趟的間隔。那是同一份內容重新簽了一次,不是換了一把新鑰匙。
這裡還有一個坑。iat 只有秒級精度。兩趟 refresh 落在同一秒內時,payload 一模一樣。簽出來就是逐字相同的字串。我第一次跑的時候兩趟接著送,看到兩張長得一模一樣,差點就把「沒換新」寫成結論了。
所以字串有沒有變,什麼都證明不了。真正該問的是另一個問題:舊的那張,還能不能用。
我拿一張已經送出去用過三次的 refresh token 再送一次。伺服器回 200,照收。

有些授權伺服器每次 refresh 都會發一張新的 refresh token。舊的那張同時失效,這叫做 refresh token rotation。好處是萬一舊的外洩了,攻擊者用一次之後就會被伺服器發現「這張已經被用過了」。整條授權鏈可以跟著作廢。
這台沒有做這件事。用過的那張沒有被作廢,整條授權鏈也沒有跟著作廢。
這裡要分清楚兩份文件。SMART 2.2 沒有單獨要求 rotation。OAuth 的安全基準 RFC 9700 則有。
它要求 public client 的 refresh token 兩者擇一。一是採 rotation,二是把 token 綁定到特定的 client。我們這個純前端 app 正是 public client,而這台兩樣都沒做。
所以你不能假設 rotation 存在,也不能假設它不存在:
- 程式要照 rotation 寫:每次 refresh 後都把回應裡的
refresh_token存回去,覆蓋舊的。反正它每次都給你一串新字串,存就對了;真的有 rotation 而你沒存,下次 refresh 就失效 - 安全評估要照沒有 rotation 假設:那張 token 外洩就是長期有效,不要指望伺服器替你止血
那把鑰匙要放哪裡
XSS 是跨站腳本攻擊。簡單說,就是有一段不是你寫的 JavaScript 跑在你的頁面上。那段腳本的權限跟你自己寫的程式碼完全一樣。
純前端的 app 沒有後端可以藏東西,四個選項都在瀏覽器裡:
| 放哪 | 重整後還在 | 關掉分頁還在 | XSS 拿得到 |
|---|---|---|---|
| 變數(記憶體) | 不在 | 不在 | 執行期間拿得到 |
sessionStorage |
在 | 不在 | 拿得到 |
localStorage |
在 | 在 | 拿得到 |
| 後端 session | 在 | 看設定 | 拿不到 |

localStorage、sessionStorage、閉包裡的變數,那段腳本三個都讀得到。 差別只在「持續多久」,不在「能不能拿」。
fhirclient 預設用 sessionStorage,這是個合理的折衷:重整頁面不用重新授權,關掉分頁就清掉。
真的要保護 refresh token,唯一有效的做法是它根本不要進到瀏覽器。由後端持有,前端只拿短命的 access token。或者連 access token 都不給,前端每次都問後端。有後端的架構在這件事上天生佔便宜。長期憑證從頭到尾不經過瀏覽器,惡意腳本沒有東西可以讀。
所以這是架構問題:
- 純前端 app:接受
sessionStorage,而且一開始就不要去要offline_access。使用者離開就重新授權,比留一把長期鑰匙在瀏覽器裡安全 - 有後端:refresh token 留在後端,這是唯一能真正保護它的地方
fhirclient 幫你做到哪裡
翻 vendor/ 那個檔案,它有 refresh() 和 refreshIfNeeded() 兩個方法。
換的時機不是靠計時器。client.request() 每次送 FHIR 請求之前會先呼叫 refreshIfNeeded()。那個方法比對 expiresAt 與現在時間,剩不到十秒或已經過期才真的去換。所以你不用自己算時間,但也只有在你發請求時它才會動。
它做的是前面說的「照 rotation 寫」那一半,安全評估那一半沒有人能替你做。
跟著做:換一張新的,再把舊的送一次
起點:day13 之後的 smart-app,app.js 用 fhirclient,畫面顯示病人姓名與操作者。day13 第四步跑的是醫護身分,今天換回病人身分。FHIR_BASE_URL 改回 Patient Standalone Launch 那一串,SCOPE 裡的 user/Practitioner.r 改回 patient/Patient.r。
產出:SCOPE 加上 offline_access,畫面上多一顆按鈕可以手動換一次 token。你會看到 access token 換了。而剛剛那張舊的 refresh token 再送一次還是收。
第一步,要 refresh token
SCOPE 加一個:
const SCOPE = 'launch/patient patient/Patient.r openid fhirUser offline_access'
沒加這個,token response 就不會有 refresh_token,後面三步全都跑不起來。
第二步,加一顆按鈕
index.html 的 #connect 按鈕後面再加一顆:
<button id="refresh" disabled>手動換一次 token</button>
第三步,接上 refresh
showPatient() 裡加這段:
const refreshButton = document.querySelector('#refresh')
const before = client.state.tokenResponse
console.log('access token 尾八碼:', before.access_token.slice(-8))
console.log('refresh token 尾八碼:', before.refresh_token?.slice(-8) ?? '(沒有)')
console.log('過期時間:', new Date(client.state.expiresAt * 1000).toLocaleTimeString())
refreshButton.disabled = false
refreshButton.addEventListener('click', async () => {
const oldRefreshToken = client.state.tokenResponse.refresh_token
await client.refresh()
const after = client.state.tokenResponse
console.log('換過之後')
console.log(' access token 尾八碼:', after.access_token.slice(-8))
console.log(' refresh token 尾八碼:', after.refresh_token?.slice(-8) ?? '(沒有)')
console.log(' access token 有換新嗎:', after.access_token !== before.access_token)
console.log(' refresh token 字串一樣嗎:', after.refresh_token === before.refresh_token)
const retry = await fetch(client.state.tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: oldRefreshToken,
client_id: CLIENT_ID,
}),
})
console.log(' 舊的那張再送一次:', retry.status, retry.ok ? '還能用' : '被拒絕')
})
最後那一段 fetch 才是重點。前面幾行都只是在看字串,只有它真的去問伺服器「這張用過的,你還收不收」。
只印尾八碼是刻意的。整串 token 印在 console 裡,你螢幕分享或截圖時就送出去了。養成只印片段的習慣。
第四步,先清掉舊的 session 再按下去
這一步不能直接重整。 day13 那次授權還留在 sessionStorage 裡,鍵名叫 SMART_KEY。FHIR.oauth2.ready() 會直接撿起它接著用,不會帶著新加的 offline_access 重跑一次授權。
那個畫面很難察覺哪裡不對。病人姓名照樣顯示,但 scope 還是舊的那一串。refresh token 印出 (沒有)、#refresh 維持 disabled、#connect 被移掉。兩顆按鈕都按不了,而且不會有任何錯誤訊息。
所以先在 Console 打這一行,再重整:
sessionStorage.clear()
開一個無痕分頁也行。之後每次改 SCOPE 都要來這麼一次,sessionStorage 記的是上次授權的結果,不是你現在寫的設定。
清乾淨之後走完授權,按那顆按鈕。
預期結果:access token 換新、過期時間往後推一小時。refresh token 也是一串新字串,但舊的那張再送一次仍然回 200。你的尾八碼跟我的不會一樣。

完整檔案
到這裡第二幕的程式就完成了。這一段是給中途接上或哪裡壞掉的人。把 day04 建好的 smart-app 清空成三個檔案,貼上以下內容,再把 FHIR_BASE_URL 換成你自己那一串就能跑。不需要回頭補做 day05 到 day13。
不想手貼的話,這幾個檔案在 GitHub 上有一份,MIT 授權,資料夾是 day14-token-lifecycle/。
index.html:
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<title>SMART App</title>
</head>
<body>
<div id="app">載入中…</div>
<button id="connect" disabled>連線到 FHIR 伺服器</button>
<button id="refresh" disabled>手動換一次 token</button>
<script src="vendor/fhir-client.pure.min.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>
app.js:
// 換成自己的:Launcher 選 Patient Standalone Launch,
// 複製 Server's FHIR Base URL 那一整串(含 /sim/)
export const FHIR_BASE_URL =
'https://launch.smarthealthit.org/v/r4/sim/WzMsIiIs…/fhir'
const CLIENT_ID = 'my-smart-app'
const SCOPE =
'launch/patient patient/Patient.r openid fhirUser offline_access'
const result = document.querySelector('#app')
const connectButton = document.querySelector('#connect')
const refreshButton = document.querySelector('#refresh')
FHIR.oauth2
.ready()
.then(showEverything)
.catch(() => {
result.textContent = '準備好了,按按鈕開始授權'
connectButton.disabled = false
connectButton.addEventListener('click', () => {
FHIR.oauth2.authorize({
iss: FHIR_BASE_URL,
clientId: CLIENT_ID,
scope: SCOPE,
redirectUri: window.location.pathname,
})
})
})
async function showEverything(client) {
connectButton.remove()
const token = client.state.tokenResponse
const idToken = client.getIdToken()
console.log('patient id:', client.patient.id)
console.log('encounter id:', client.encounter.id)
console.log('fhirUser:', idToken?.fhirUser)
console.log('aud 是不是我:', idToken?.aud === CLIENT_ID)
console.log('scope:', token.scope)
console.log('access token 尾八碼:', token.access_token.slice(-8))
console.log('refresh token 尾八碼:', token.refresh_token?.slice(-8) ?? '(沒有)')
const patient = await client.patient.read()
const name = patient.name?.[0]
const who = name ? `${name.given?.join(' ')} ${name.family}` : patient.id
result.textContent = `${who},生日 ${patient.birthDate}`
if (!token.refresh_token) return
refreshButton.disabled = false
refreshButton.addEventListener('click', async () => {
const before = client.state.tokenResponse.access_token
const oldRefreshToken = client.state.tokenResponse.refresh_token
await client.refresh()
const after = client.state.tokenResponse.access_token
console.log('access token 有換新嗎:', after !== before)
const retry = await fetch(client.state.tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: oldRefreshToken,
client_id: CLIENT_ID,
}),
})
console.log('舊的那張再送一次:', retry.status)
})
}
vendor/fhir-client.pure.min.js 照 day04 那行 curl 抓下來就好。
跑起來的預期結果:畫面顯示病人姓名與生日。console 印出 patient id、fhirUser 與 scope。兩張 token 的尾八碼也會印出來。按下按鈕後 access token 換新。舊的那張 refresh token 再送一次回 200。
貼完記得先 sessionStorage.clear() 再重整,理由跟第四步一樣。
小結
expires_in 是「還有幾秒」不是「什麼時候」。收到當下就要換算成絕對時間,而且不能寫死一小時。
refresh 那一趟只送三個欄位,code_verifier 與 redirect_uri 都不必送。回來的九個欄位卻與首次一模一樣。真正的差別是它不需要使用者在場。這是它好用的原因,也是它危險的原因。
這台不做 refresh token rotation,用過的那張還是收。要驗這件事別去比字串,字串每次都會變,那只是同一份內容重新簽了一次。程式要照有 rotation 寫,安全評估要照沒有 rotation 想。
儲存的取捨其實是架構問題。瀏覽器裡沒有一個地方擋得住 XSS。所以純前端 app 最務實的作法是不要 offline_access,讓使用者重新授權;要長期 token,就把它放在後端。
第二幕到這裡結束。十天前我們只有一個 base URL。現在這個 app 會自己問端點、走完 PKCE 授權、解讀 context、認出操作者。它還會在 token 過期前換一張新的。
不過它到現在為止,一筆臨床資料都還沒讀過。明天進第三幕,開始把資料撈出來變成畫面。