iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

本文同步發表於個人部落格:從頭跑完一次授權


前面四天下來,專案裡多了兩個檔案。discovery.js 問端點,pkce.jscode_verifiercode_challenge。另外兩件事只在瀏覽器上手動做過,還沒寫成程式。一是組 authorize 網址,二是拿 code 換 token。今天把這四件事接成一支自己會跑的 app。

接的過程中會冒出一個新參數 state。先看火線超人怎麼用這個參數,再回來寫自己的。

火線超人有兩套 OAuth callback。第一套是使用者用 LINE 帳號登入,第二套是火線超人去跟 FHIR 伺服器要授權。兩套處理 state 的方式差很多。

LINE Login 那套只有兩行:

session[:line_login_state] = auth_request[:state]
# ...使用者回來之後
unless state && session[:line_login_state] == state

發起時丟進 session,回來時比對,結束。

FHIR 授權那套卻做了一整個 model、一張資料表、一個到期欄位:

def self.create_for_user(line_user_id, fhir_server, organization_id: Current.organization&.id)
  create!(
    line_user_id: line_user_id,
    fhir_server: fhir_server,
    state: generate_state,
    code_verifier: generate_code_verifier,
    organization_id: organization_id,
    expires_at: EXPIRY_MINUTES.minutes.from_now
  )
end

同一個參數,為什麼一邊兩行、一邊要建表?

看一下 callback 那裡就知道了:

oauth_state = FhirOauthState.find_valid_by_state(state)
# ...
line_user_id = oauth_state.line_user_id
service = FhirOauthService.new(line_user_id)

FHIR 授權那套是拿 state 去把使用者查回來的。因為授權連結是在 LINE 裡點開的,使用者被送回來時進的是伺服器的一個公開網址。沒有登入 session、也沒有 cookie 能告訴你這是誰。這一趟 callback 的網址上只有一個值可用,就是 state

所以 state 在這裡同時做兩件事。一是比對「這次 callback 是不是我發起的」。二是找回「當初是哪個 LINE 使用者發起的」。

注意這裡找回的不是病人。病人是誰要等換到 token 才知道,那串 patient id 是伺服器給的。

今天要寫的程式規模小很多。沒有 model、沒有資料表,也不用拿 state 把使用者查回來。但另一件事跑不掉。發起授權時自己留一份 state,等使用者被送回來,再拿出來比對是不是同一份。

state 要擋的是「塞」,不是「偷」

我們先來弄清楚 state 在擋什麼,因為它跟昨天講到的 PKCE 常常被混為一談。

想像一下,攻擊者先用自己的帳號在你的 app 上要一次授權。一旦拿到 code 就先停下來不去換 token。然後他想辦法讓你點下 https://你的app/?code=他的code 這個網址。

你的瀏覽器打開它。你的 app 看到網址上有 code,沒有檢查就直接拿去換 token。換回來的是攻擊者帳號的 token。從那一刻起,你在這個 app 裡看到的、寫進去的資料,全都掛在攻擊者的帳號底下。

state 的擋法很土法煉鋼,一共三步。第一,發起授權時產生一串猜不到的亂數,自己留一份。第二,把同一份放進 authorize URL 送出去,授權伺服器會原樣還給你。第三,回來時拿兩份比對,不符就整趟丟掉。攻擊者手上的 code 配的是他自己那一次的 state,塞不進你這次的流程。

跟昨天擺在一起看,兩者的方向剛好相反:

兩種攻擊方向的對照圖,標題「PKCE 擋偷走,state 擋塞入」,副標「兩個參數,兩個相反方向」。上下兩條車道的角色位置完全相同,左邊都是「你的 app」、右邊都是「攻擊者」,差別只在箭頭方向。上面那條是白底卡片,左上角一個淺藍方塊寫著「偷」,旁邊是「你的 code 被撿走」,右上角標著 PKCE。中間一條箭頭由左的你的 app 指向右的攻擊者,箭頭上方寫著「你的 code 從網址列流出去」,箭頭中央被一個深藍色圓形的盾牌圖示切斷,箭頭下方寫著「他換 token 時交不出 code_verifier」並附一個 400 標籤。下面那條是深藍色實心卡片,左上角一個半透明白色方塊寫著「塞」,旁邊是「別人的 code 被塞給你」,右上角以珊瑚色標著 state。中間的箭頭方向相反,由右的攻擊者指向左的你的 app,上方寫著「他先取得的 code 被誘導進你的網址列」,箭頭中央被一個珊瑚色圓形的盾牌圖示切斷,下方寫著「回來的 state 不是你送出去的那張」並附一個「整趟丟掉」標籤。圖最下方註記寫著方向相反,所以兩個都要,沒有誰取代誰,verifier 與 state 都是用完就清

所以兩個都要,沒有誰取代誰。

把四件事接起來

四件事就是問端點、產 PKCE、送使用者去登入、拿 code 換 token。前兩件前面寫過了,專案裡已經有 day06 的 discovery.js 和 day08 的 pkce.js。今天新增 auth.js,把後兩件補上並串起來。

出發那一段長這樣:

export async function startAuthorization({ endpoints, fhirBaseUrl }) {
  const { verifier, challenge } = await createPkcePair()
  const state = randomBase64Url(32)

  savePkceVerifier(verifier)
  sessionStorage.setItem(STATE_KEY, state)

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    scope: SCOPE,
    redirect_uri: redirectUri(),
    aud: fhirBaseUrl,
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  })

  window.location.assign(`${endpoints.authorize}?${params}`)
}

state 直接借用昨天寫好的 randomBase64Urlstate 要的性質跟 code_verifier 一樣,就是別人猜不到。既然昨天已經有一個合格的亂數產生器,沒必要再寫第二份。

URLSearchParams 幫你把 scope 裡的空白、aud 裡的斜線全部跳脫好,比自己接字串安全。前天你手工貼網址時,那些空白和斜線都要自己編碼,應該還記得有多囉唆。

回來那一段是重點:

export async function completeAuthorization({ endpoints, redirect }) {
  const expectedState = sessionStorage.getItem(STATE_KEY)
  sessionStorage.removeItem(STATE_KEY)

  if (!expectedState || redirect.state !== expectedState) {
    throw new Error('state 對不起來,這次 callback 不是我們發起的')
  }

  const verifier = takePkceVerifier()
  if (!verifier) {
    throw new Error('找不到 code_verifier,授權要重來一次')
  }
  // ...接著才 POST 去換 token
}

這裡有兩個刻意的安排。第一,先比對 state,再碰 code。比對沒過就直接丟出錯誤,code 連讀都不讀。

第二,兩個暫存的東西都用完即清。不管比對過不過,state 都先從 sessionStorage 刪掉。verifier 也一樣,用 takePkceVerifier 取出後立刻消失。這兩樣東西的壽命就是一次授權,留著只是多一個會被讀到的值。火線超人換完 token 就 consume! 掉那筆記錄,是同一個習慣。

還有一個容易踩的地方。redirect_uri 在授權那一趟和換 token 那一趟必須完全一樣,所以我把它抽成函式,只算一次:

export function redirectUri() {
  return window.location.origin + window.location.pathname
}

寫死成字串也可以,但只要埠號改過,昨天那個 Invalid redirect_uri parameter 就會再出現一次。錯誤訊息只說對不上,不會告訴你是哪一趟寫錯。

換到 token 之後,app.js 還多做一件事:

window.history.replaceState({}, '', window.location.pathname)

把網址列上的 code 擦掉。它已經用過了,留著只會進到瀏覽器的歷史紀錄,也可能被使用者複製貼上傳出去。

伺服器真的有在驗嗎

拿到 token 之後,很自然會想確認一下它有沒有用。

直覺的驗法是把 token 拿掉,看伺服器會不會擋。這樣驗在這台 sandbox 上會得到錯的結論。完全不帶 Authorization header 去讀 Patient,它回 HTTP 200。這台是開放給大家練習的,資源端沒有真的把關。

換個方式問。與其把 token 拿掉,不如故意帶一張假的去。

await fetch(`${FHIR_BASE_URL}/Patient/${patientId}`, {
  headers: { Authorization: 'Bearer not-a-real-token' },
})
// HTTP 401,body 是 Invalid token: jwt malformed

兩種請求的對照圖,標題「不帶 token 回 200,亂寫的 token 回 401」,副標「差別在有沒有東西可以驗」。標題下方是一條淺米色橫帶,左邊灰字寫「兩次都是讀」,接著是等寬字體的 Patient 加一個尖括號包住的 id,最右邊灰字寫「差別只在 Authorization header」。橫帶下方左右並排兩張卡片。左邊是白底加淺色外框的卡片,左上角一個灰色的盾牌被斜線劃掉的圖示,旁邊粗體寫「不帶 token」,同一行最右邊灰字寫「沒東西可以驗」;卡片中段是等寬字體的欄位名 Authorization,下面一個淺灰色方塊寫著「完全不帶」;一條細分隔線之後,左邊一個淺藍灰色方塊裡是大字的 200,右邊寫「沒有 token 也讀得到」。右邊是深藍色實心卡片,左上角一個珊瑚色的盾牌打叉圖示,旁邊白色粗體寫「亂寫的 token」,同一行最右邊淺灰字寫「有東西可以驗」;卡片中段是淺色的欄位名 Authorization,下面一個半透明白色方塊裡是等寬字體的 Bearer not-a-real-token;一條細分隔線之後,左邊一個珊瑚色方塊裡是白色大字的 401,右邊白字寫「token 存在時會驗簽」。圖最下方註記寫著有帶 token 它就會驗,只是沒帶的時候懶得管

真實的 EHR 伺服器兩種情況都會擋。這台的寬鬆是它自己的設定,不是 SMART 的規定。

還有一件事要先講清楚。這台的 access_token 剛好是一個 JWT,貼到解碼器上看得到 scopecontext 之類的內容。這是它的實作細節,不是標準。SMART 沒有規定 access token 的格式。

對你的 app 來說,它就是一串看不懂也不必看懂的字串。照著原樣掛在 header 上就對了。不要去解它、更不要依賴裡面的欄位。

跟著做:完整跑完第一次授權

起點:照著走下來的話,是 day08 之後的 smart-app。裡面已經有 day06 的 discovery.js 與 day08 的 pkce.js。今天的 auth.js 會直接拿那個存好的 verifier 去換 token。

day04 之後的任何狀態也都行,因為這一篇是檢查點。下面給的是這個階段所有檔案的完整內容。中間漏做過哪一篇都沒關係,整份貼上就能對齊。

產出:一個能自己走完 standalone 授權的 app。console 印出 access token 與 patient id。day10 之後都從這裡繼續。

專案結構:

smart-app/
├── index.html
├── app.js
├── discovery.js
├── pkce.js
├── auth.js
└── vendor/fhir-client.pure.min.js

vendor/ 裡那個檔案今天還用不到,但別刪,day12 會用回來。

index.html

<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="utf-8" />
    <title>SMART App</title>
  </head>
  <body>
    <button id="connect" disabled>連線到 FHIR 伺服器</button>
    <div id="app">正在讀取伺服器設定…</div>
    <script src="vendor/fhir-client.pure.min.js"></script>
    <script type="module" src="app.js"></script>
  </body>
</html>

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 ?? [],
  }
}

pkce.js

const VERIFIER_KEY = 'smart_code_verifier'

// 產生一段 URL 安全的高熵亂數字串
export function randomBase64Url(byteLength = 32) {
  const bytes = crypto.getRandomValues(new Uint8Array(byteLength))
  return base64UrlEncode(bytes)
}

export async function createPkcePair() {
  const verifier = randomBase64Url(32)
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier)
  )
  const challenge = base64UrlEncode(new Uint8Array(digest))
  return { verifier, challenge }
}

export function savePkceVerifier(verifier) {
  sessionStorage.setItem(VERIFIER_KEY, verifier)
}

// 取出後立刻清掉:verifier 只該被用一次
export function takePkceVerifier() {
  const verifier = sessionStorage.getItem(VERIFIER_KEY)
  sessionStorage.removeItem(VERIFIER_KEY)
  return verifier
}

function base64UrlEncode(bytes) {
  const binary = String.fromCharCode(...bytes)
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

auth.js

import {
  createPkcePair,
  savePkceVerifier,
  takePkceVerifier,
  randomBase64Url,
} from './pkce.js'

export const CLIENT_ID = 'my-smart-app'
export const SCOPE = 'launch/patient patient/*.rs openid fhirUser offline_access'

const STATE_KEY = 'smart_state'

// redirect_uri 要和換 token 時送的那個一模一樣,所以只在這裡算一次
export function redirectUri() {
  return window.location.origin + window.location.pathname
}

export async function startAuthorization({ endpoints, fhirBaseUrl }) {
  const { verifier, challenge } = await createPkcePair()
  const state = randomBase64Url(32)

  savePkceVerifier(verifier)
  sessionStorage.setItem(STATE_KEY, state)

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    scope: SCOPE,
    redirect_uri: redirectUri(),
    aud: fhirBaseUrl,
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  })

  window.location.assign(`${endpoints.authorize}?${params}`)
}

// 讀網址列上授權伺服器送回來的東西
export function readRedirect() {
  const params = new URLSearchParams(window.location.search)
  return {
    code: params.get('code'),
    state: params.get('state'),
    error: params.get('error'),
    errorDescription: params.get('error_description'),
  }
}

export async function completeAuthorization({ endpoints, redirect }) {
  const expectedState = sessionStorage.getItem(STATE_KEY)
  sessionStorage.removeItem(STATE_KEY)

  if (!expectedState || redirect.state !== expectedState) {
    throw new Error('state 對不起來,這次 callback 不是我們發起的')
  }

  const verifier = takePkceVerifier()
  if (!verifier) {
    throw new Error('找不到 code_verifier,授權要重來一次')
  }

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code: redirect.code,
    redirect_uri: redirectUri(),
    client_id: CLIENT_ID,
    code_verifier: verifier,
  })

  const response = await fetch(endpoints.token, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  })

  if (!response.ok) {
    throw new Error(`換 token 失敗:HTTP ${response.status} ${await response.text()}`)
  }

  return response.json()
}

app.js

別抄我的 FHIR_BASE_URL。記得換成你自己從 Launcher 複製的 Server's FHIR Base URL。

import { discoverEndpoints } from './discovery.js'
import { startAuthorization, readRedirect, completeAuthorization } from './auth.js'

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

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

main().catch((error) => {
  result.textContent = `出錯了:${error.message}`
})

async function main() {
  const endpoints = await discoverEndpoints(FHIR_BASE_URL)
  const redirect = readRedirect()

  if (redirect.error) {
    result.textContent =
      `授權沒有完成:${redirect.error}(${redirect.errorDescription ?? '沒有進一步說明'})`
    return
  }

  if (!redirect.code) {
    result.textContent = '準備好了,按按鈕開始授權'
    connectButton.disabled = false
    connectButton.addEventListener('click', () => {
      startAuthorization({ endpoints, fhirBaseUrl: FHIR_BASE_URL })
    })
    return
  }

  connectButton.remove()
  result.textContent = '正在用 code 換 token…'

  const token = await completeAuthorization({ endpoints, redirect })

  window.history.replaceState({}, '', window.location.pathname)

  console.log('access token:', token.access_token)
  console.log('patient id:', token.patient)
  console.log('scope:', token.scope)
  console.log('expires_in:', token.expires_in, '秒')

  result.textContent = `授權完成,patient id 是 ${token.patient}`
}

main() 只靠一件事分岔,就是網址上有沒有 code。沒有 code,代表使用者剛開頁面,那就等他按按鈕。有 code,代表他剛從授權伺服器被送回來,那就接著換 token。同一個檔案同時當首頁和 callback 頁。這是 standalone launch 最省事的寫法。

跑起來

python3 -m http.server 5173

http://localhost:5173,按 F12 打開 console,然後按畫面上那顆按鈕。接著會依序看到 Patient Login、Authorize App Launch。按 Approve。

預期結果:被送回 http://localhost:5173/,網址列上的 code 一閃就被清掉。畫面顯示「授權完成,patient id 是 …」。console 有四行輸出,其中 access token 是一串六百多字元的字串。圖上那個 669 是我這次跑出來的長度。這個數字會隨 scope 字串長短變動,你的跟我的不會一樣,對不上不是你做錯。

執行結果圖,標題「授權跑完的樣子」,副標「網址列已經清乾淨,Console 四行就是交付物」。畫面上有三個綠色圓形編號,與圖片下方三條註記一一對應。一個白色瀏覽器視窗,網址列顯示 http://localhost:5173/,後面接一段灰色加刪除線的 code 與 state 參數,表示那一段原本在但已經被擦掉,右上角掛著編號 1。頁面區塊是淺綠底、左緣有深綠直線的成功訊息,左緣掛著編號 2,內容是「授權完成,patient id 是 018f428e-34f6-4707-8009-5ad742f901e7」。下方 Console 面板左緣掛著編號 3,四行輸出:access token 是珊瑚色的 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6… 後面灰字註明共 669 字元;patient id 是 018f428e-34f6-4707-8009-5ad742f901e7;scope 是 launch/patient patient/*.rs openid fhirUser offline_access;expires_in 是 3600 秒。圖片下方三條綠色編號註記:網址列上的 code 一閃就被 history.replaceState 擦掉了,刪除線那段是它原本的位置;patient id 是授權伺服器順手告訴你的 launch context,day12 專門講它;access token 是不透明字串,照原樣掛在 Authorization header 上就好,不要去解它

再確認一次伺服器有在驗

console 裡貼上,{base}{patientId} 換成你自己的值:

const r = await fetch('{base}/Patient/{patientId}', {
  headers: { Authorization: 'Bearer not-a-real-token' },
})
console.log(r.status, await r.text())   // 401 Invalid token: jwt malformed

再把 headers 整個拿掉跑一次,這台會回 200。看到 200 不要慌,那是這台 sandbox 的寬鬆設定,不是你哪裡寫錯了。

如果卡住了

畫面顯示 出錯了:state 對不起來,這次 callback 不是我們發起的:多半是你把 callback 網址複製到新分頁貼上了。sessionStorage 是綁分頁的,換分頁就什麼都讀不到,回原本那個分頁重按按鈕即可。中途關過分頁也是同一個症狀。

換 token 失敗:HTTP 401 … Invalid redirect_uri parameter:兩趟送出的 redirect_uri 不一致。http://localhost:5173http://127.0.0.1:5173 對瀏覽器來說是兩個不同的位址,中途換過就會對不上,埠號改過也一樣。

出錯了:discovery 回了 HTTP 404FHIR_BASE_URL 尾巴多了一個斜線,變成 …/fhir//.well-known/…。去掉再試。

同樣的事,函式庫幾行做完

你的 vendor/ 裡就有 fhirclient,同一趟授權它是這樣寫的:

FHIR.oauth2.authorize({
  iss: FHIR_BASE_URL,
  clientId: 'my-smart-app',
  scope: 'launch/patient patient/*.rs openid fhirUser offline_access',
  redirectUri: 'index.html',
})

// 回來之後
const client = await FHIR.oauth2.ready()
console.log(client.state.tokenResponse.access_token, client.patient.id)

discovery、PKCE、state、換 token、清網址,它全包了。

那我們前四天在幹嘛?在看它包起來的那些東西。等到 day12 真的換成 fhirclient,你會知道 authorize() 那一行底下發生了什麼事。哪天遇到某家醫院的伺服器行為跟規範對不上,你才查得出問題在哪一段。

小結

第二幕走到這裡,你的 app 已經能自己完成一次 standalone 授權。問出端點、產生 PKCE、送使用者去登入、驗證 state、用 code 換到 token。

今天多出來的那個 state,防的是別人把 code 塞給你。跟昨天 PKCE 防的「你的 code 被偷」剛好是反方向。火線超人把 state 從 session 升級成一張資料表。因為那一趟 callback 上,只剩這一個值能認人。

你手上現在有一張 access token 和一個 patient id。但你有沒有注意到,那串 scope 是我直接給你的。patient/*.rs 到底在要什麼權限,launch/patient 又解鎖了什麼,都還沒解釋。

明天開始講 scopes,先從 patient/user/system/ 這三個前綴講起。


上一篇
Day08 - 為什麼需要 PKCE
下一篇
Day10 - 一個 scope 分成三個部分
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言