iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

本文同步發表於個人部落格: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_typerefresh_token,另外允許帶 scopeclient_id 不在清單上。fhirclient 預設也不送它,要另外開一個選項才會加。

為了乾淨地比一次,我把兩趟接在一起跑。先走完授權,拿 code 換到第一張 token。再直接拿那張回應裡的 refresh_token 換第二次。同一串 code 衍生的連續兩趟,比起來才不會混到別次實跑的值。

送出去的欄位,第一趟有五個,第二趟只剩三個。兩趟共有的只有 grant_typeclient_id

少掉的那幾個裡,code_verifier 的理由最清楚。PKCE 保護的是 code 那一段交換。授權伺服器要確認的是,拿 code 來換的人是不是當初發起授權的那個人。

第二趟根本沒有 code,憑據換成了 refresh token。沒有 code 就沒有東西要比對,code_verifier 自然不用送。

redirect_uri 是同一個道理。它在第一趟的工作是比對 code 上綁定的那個位址。第二趟沒有 code 要比對,它就跟著消失了。

這件事在 token 本身也留下痕跡。我把兩趟的 access token 都解開看過。第一趟的內容帶著 code_challengecode_challenge_method,第二趟沒有這兩欄,長度也跟著從 669 字元掉到 508。這兩個數字會隨 scope 字串長短變動,你跑出來會是另外兩個數。

能解開是這台 SMART Health IT Launcher 的實作細節。day09 講過了。

回來的東西則幾乎沒有差別。兩趟都是 200,回應的九個欄位名與出現順序一模一樣。expires_in 都是 3600,scopepatient 也都原樣。

首次換 token 與 refresh 的送出欄位對照圖,cream 底色,標題「首次換 token 與 refresh,送的欄位差在哪」,副標「同一串 code 衍生的連續兩趟,兩趟都是 POST {base}/auth/token」。畫面上半部分左右兩欄。左欄標「第一趟 首次換 token」,同一行右側是一個放大的深藍色數字 5 加灰色小字「個欄位」,下方一塊深藍色圓角區塊,由上而下五行白色粗體等寬字:grant_type=authorization_code、code、code_verifier、redirect_uri、client_id。右欄標「第二趟 refresh」,同一行右側是一個放大的珊瑚色數字 3 加灰色小字「個欄位」,下方同尺寸的深藍色圓角區塊,五行與左欄逐列對齊:第一行白色粗體 grant_type=refresh_token;第二行白色粗體 refresh_token,右側掛一個灰色小字標籤「取代 code」;第三行 code_verifier 與第四行 redirect_uri 都是灰字並被一條珊瑚色刪除線劃掉,右側各掛一個珊瑚色小字標籤「不必送」;第五行白色粗體 client_id。兩欄下方一整條淺米色橫帶,開頭灰字寫「回來的九個欄位,」接深藍色粗體「兩趟的欄位名與順序完全相同」,底下兩行等寬字列出那九個欄位名:access_token、token_type、expires_in、scope、id_token、refresh_token、need_patient_banner、smart_style_url、patient。圖最下方一整條深藍色橫幅,第一行白色粗體寫「送出去五個欄位變三個,回來的九個欄位名一模一樣」,第二行珊瑚色寫「兩趟都是 200,換掉的只有 access_token 與 id_token,其餘七欄同值」

這兩趟真正的差別不在欄位。第一趟那串 code 是使用者登入、按下同意才換到的,第二趟從頭到尾沒有使用者。refresh 完全不需要使用者在場,這是它的用途,也是它的風險。

還有一個數字先記著。這台發的 refresh token 效期是一年,access token 是一小時。一樣是解開來看的,不是標準規定。一小時對一年,這個落差就是等一下要談儲存位置的原因。

這台 Launcher 不作廢舊的 refresh token

我本來想用最直覺的方法驗這件事。refresh 一次,比對前後兩張 refresh token 的字串。

這個方法會騙人。

回來的 refresh_token 字串每次都不一樣。但把它解開看 payload,contextscopeuser 三個欄位完全相同。只有 iatexp 各往後移,移的秒數剛好等於兩趟的間隔。那是同一份內容重新簽了一次,不是換了一把新鑰匙。

這裡還有一個坑。iat 只有秒級精度。兩趟 refresh 落在同一秒內時,payload 一模一樣。簽出來就是逐字相同的字串。我第一次跑的時候兩趟接著送,看到兩張長得一模一樣,差點就把「沒換新」寫成結論了。

所以字串有沒有變,什麼都證明不了。真正該問的是另一個問題:舊的那張,還能不能用。

我拿一張已經送出去用過三次的 refresh token 再送一次。伺服器回 200,照收。

連續兩次 refresh 回傳的 refresh token 對照圖,cream 底色,標題「回來那張是新字串,不是新鑰匙」,副標「解開 payload 看,只有時間戳往後移」。畫面上半部左右兩塊等寬的深藍色圓角區塊。左塊上方灰色小字標「第 1 次 refresh 回來的」,區塊內第一行淺灰小字 refresh_token 尾八碼,第二行是放大的白色粗體等寬字 TijV3s5Y。右塊上方灰色小字標「第 2 次 refresh 回來的」,區塊內同樣兩行,尾八碼為 YvZUv7J4。兩塊之間有一條淺灰色水平箭頭由左指向右,箭頭上方灰色小字寫「間隔 3.092 秒」,下方珊瑚色粗體寫「字串不同」。中段灰色小字標「兩張解開來逐欄比對」,下方三張白底圓角卡片由上而下排列:第一張左邊等寬字寫 context、scope、user,右邊掛一個深藍色圓角標籤「三個都相同」;第二張左邊寫 iat,右邊是灰色的 1786113138 接一個灰色箭頭再接珊瑚色粗體的 1786113141;第三張左邊寫 exp,右邊是灰色的 1817649138 接箭頭再接珊瑚色粗體的 1817649141。卡片下方一行灰色小字寫「兩個時間戳都往後移 3 秒,剛好是兩趟的間隔」。圖最下方一整條深藍色橫幅,第一行白色粗體寫「舊的那張再送一次,伺服器仍然回 200」,第二行珊瑚色寫「這台不做 refresh token rotation,輪換不是 SMART 的要求」

有些授權伺服器每次 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 看設定 拿不到

token 儲存位置的取捨矩陣,標題「四個位置,只有後端 session 讓 XSS 拿不到」,副標「差別只在活多久,不在拿不拿得到」。一張四列三欄的表格,欄位分別是「重整頁面後」「關掉分頁後」「XSS 拿得到嗎」。變數(記憶體)那列是叉、叉、珊瑚色底白字的「拿得到」;sessionStorage 是綠勾、叉、珊瑚色的「拿得到」;localStorage 是綠勾、綠勾、珊瑚色的「拿得到」;後端 session 是綠勾、「看設定」、深藍色底白字的「拿不到」。最右欄前三列是同樣的珊瑚色橫條,只有最後一列是深藍色,形成明顯對比。表格下方一整條深藍色區塊寫著只要有一段惡意腳本跑在你的頁面上,localStorage、sessionStorage、閉包裡的變數,它全都讀得到;真的要保護 refresh token,唯一有效的做法是「它根本不要進到瀏覽器」,最後這句以珊瑚色標示。最下方兩行灰色小字說明 fhirclient 預設用 sessionStorage 是務實的折衷,以及純前端 app 的另一個選擇是不要 offline_access

localStoragesessionStorage、閉包裡的變數,那段腳本三個都讀得到。 差別只在「持續多久」,不在「能不能拿」。

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-appapp.jsfhirclient,畫面顯示病人姓名與操作者。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_KEYFHIR.oauth2.ready() 會直接撿起它接著用,不會帶著新加的 offline_access 重跑一次授權。

那個畫面很難察覺哪裡不對。病人姓名照樣顯示,但 scope 還是舊的那一串。refresh token 印出 (沒有)#refresh 維持 disabled、#connect 被移掉。兩顆按鈕都按不了,而且不會有任何錯誤訊息

所以先在 Console 打這一行,再重整:

sessionStorage.clear()

開一個無痕分頁也行。之後每次改 SCOPE 都要來這麼一次,sessionStorage 記的是上次授權的結果,不是你現在寫的設定。

清乾淨之後走完授權,按那顆按鈕。

預期結果:access token 換新、過期時間往後推一小時。refresh token 也是一串新字串,但舊的那張再送一次仍然回 200。你的尾八碼跟我的不會一樣。

執行結果圖,淺灰綠底色,標題「換過一次 token 的樣子」,副標「驗收點是最後那一行的 200,不是上面的 true 和 false」。畫面上有四個綠色圓形編號,與圖片下方四條註記一一對應。一個白色圓角面板,上半部是頁面區,有一顆線框按鈕寫「手動換一次 token」,右邊掛著綠色編號 1 與灰字「按下去」。中間一條淺灰色橫帶寫 Console。下半部是 Console 輸出,等寬字由上而下六行:第一行是「換過之後」;第二行「access token 尾八碼:」後面接八個淺灰圓點,再往右是灰字「每次授權都不同」;第三行「refresh token 尾八碼:」後面同樣接八個淺灰圓點;第四行左側掛綠色編號 2,內容是「access token 有換新嗎:」加上放大的綠色粗體 true;第五行左側掛綠色編號 3,內容是「refresh token 字串一樣嗎:」加上灰色粗體 false,再往右是更小的灰字「證明不了什麼」;第六行左側掛綠色編號 4,內容是「舊的那張再送一次:」加上全圖最大的珊瑚色粗體 200,後面接珊瑚色粗體「還能用」。面板下方四條註記,編號 1 寫授權走完之後才會亮起來的那顆按鈕,按一次觸發一次 client.refresh 方法;編號 2 寫 true 代表這一趟確實換到新的 access_token,同一次回應的 expires_in 仍是 3600;編號 3 寫 false 代表 refresh_token 也是一串新字串,解開來看只有 iat 與 exp 往後移;編號 4 寫 200 代表用過的那張再送一次還是收,這台不作廢舊的 refresh token

完整檔案

到這裡第二幕的程式就完成了。這一段是給中途接上或哪裡壞掉的人。把 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_verifierredirect_uri 都不必送。回來的九個欄位卻與首次一模一樣。真正的差別是它不需要使用者在場。這是它好用的原因,也是它危險的原因。

這台不做 refresh token rotation,用過的那張還是收。要驗這件事別去比字串,字串每次都會變,那只是同一份內容重新簽了一次。程式要照有 rotation 寫,安全評估要照沒有 rotation 想。

儲存的取捨其實是架構問題。瀏覽器裡沒有一個地方擋得住 XSS。所以純前端 app 最務實的作法是不要 offline_access,讓使用者重新授權;要長期 token,就把它放在後端。

第二幕到這裡結束。十天前我們只有一個 base URL。現在這個 app 會自己問端點、走完 PKCE 授權、解讀 context、認出操作者。它還會在 token 過期前換一張新的。

不過它到現在為止,一筆臨床資料都還沒讀過。明天進第三幕,開始把資料撈出來變成畫面。


上一篇
Day13 - 身分識別與 fhirUser
下一篇
Day15 - 第一個 SMART app
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言