iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

本文同步發表於個人部落格:為什麼需要 PKCE


火線超人的專案裡有兩份 PKCE 程式碼。

一份在 Fhir::OAuth2Service,看起來很正統:

def generate_pkce_pair
  code_verifier = SecureRandom.hex(32)
  code_challenge = Base64.urlsafe_encode64(
    Digest::SHA256.digest(code_verifier)
  ).delete('=')

  { verifier: code_verifier, challenge: code_challenge }
end

另一份在 FhirOauthState 這個 model 上:

def self.generate_code_verifier
  SecureRandom.urlsafe_base64(32)
end

def self.generate_code_challenge(verifier)
  Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
end

兩份算出來的東西都合規,寫法上的差別只是 hexurlsafe_base64、手動 delete('=')padding: false。但實際在跑的只有下面那份,因為 initiate_authorization 拿的是 oauth_state.code_challenge,service 上那個方法沒有人呼叫。

為什麼是 model 那份活下來?看它旁邊那一行就懂了:

create!(
  line_user_id: line_user_id,
  state: generate_state,
  code_verifier: code_verifier,
  expires_at: EXPIRY_MINUTES.minutes.from_now
)

code_verifier 被存進資料庫了。

這就是 PKCE 真正的重點。算 hash 是最簡單的部分,難的是那個 verifier 得活過整趟跳轉,所以它必須跟一個「能存東西的地方」綁在一起。service 那份沒有存放的去處,它注定跑不起來。

今天先講這組亂數在擋什麼,再來處理它要放哪裡。

昨天留下來的洞

昨天結尾那串 code 不認人:它躺在網址列上,而換 token 那一趟不需要出示任何 secret。誰拿到它,誰就換得到 token。

所以問題變成,那串網址會被誰看到?

  • 進了瀏覽器的歷史紀錄,共用電腦上的下一個人翻得到
  • 瀏覽器擴充套件讀得到目前分頁的網址
  • 如果你的頁面上有第三方資源,網址可能當成 referrer 一起送出去
  • 反向代理與伺服器的存取日誌通常整串記下來

PKCE 這套機制最早被提出來,針對的其實是手機 app:redirect_uri 用的是 myapp://callback 這種自訂協定,而作業系統不保證只有你註冊得到那個協定,惡意 app 註冊同一組就能把 code 攔走。純前端的網頁 app 處境類似,同樣沒有 secret 可以證明自己是誰。

把答案拆成兩半

PKCE 的想法很簡單:既然沒有預先講好的 secret,那就當場生一個

流程只多兩個參數:

  1. 授權開始前,自己產生一串亂數 code_verifier(答案),留在手上
  2. 算出它的 SHA-256,做成 code_challenge(題目)。第一趟只送題目出門
  3. 換 token 時才把 code_verifier(答案)送出去
  4. 授權伺服器對收到的答案算一次 SHA-256,跟當初收到的題目比對,不符就拒絕

名字很容易記反,這裡先釘住:challenge 這個字本來就是「題目」的意思,而 verifier 是你最後交出去驗明正身的那個,所以它是答案。順序也幫得上忙,先生出來的一定是答案,題目是從答案算出來的,反過來算不回去。

關鍵在於題目和答案走的是不同通道。題目跟著 authorize URL 走前台,會被看到;答案只走後台的 POST,不經過網址列。

於是攻擊者就算撿到 code,也換不到 token,因為他手上沒有答案。他有的是那個 code_challenge(題目),而 SHA-256 不可反解。

PKCE 的拆解圖,標題「題目走前台,答案走後台」,副標「攻擊者撿到 code 也換不到 token」。上半部是一張白色大卡片,佔整張圖最大面積:左邊是珊瑚色邊框的方塊,標題為 code_verifier(答案),下面兩行是「43 字元亂數,自己生的」與「只留在 sessionStorage」;右邊是淺藍邊框的方塊,標題為 code_challenge(題目),下面兩行是「base64url,算出來的」與「跟著網址送出門」;兩者之間有一條深藍色實心箭頭由左指向右,上方標著 SHA-256,下方另有一條淺灰色箭頭由右指向左,中央蓋著紅字「不可反解」。中間是兩條細帶:上面一條白底虛線框,標示前台通道,內容為 GET authorize 帶粗體的 code_challenge 與 code_challenge_method=S256,右端註明「題目,攻擊者看得到」;下面一條深藍實心,標示後台通道,內容為 POST token 帶 code 與珊瑚色的 code_verifier,右端註明「答案,網址列上沒有」。最下面是一條淺紅底的帶子,左側是攻擊者圖示與「攻擊者手上」,接著三個標籤:code 打勾、code_challenge(題目)打勾、以深紅底白字標示的 code_verifier(答案)打叉,最右邊是結果 400 Missing code_verifier。圖最下方註記寫著兩半走不同通道,前台漏光了也還缺一半,這台 sandbox 不強制 PKCE,送了才驗

這也解釋了為什麼不能把 code_verifier(答案)直接當 code_challenge(題目)送。那叫 plain 模式,等於把答案寫在題目上,攔到 authorize URL 的人一併就拿到答案了。SMART App Launch 2.0 只認 S256,昨天抓的 discovery 裡 code_challenge_methods_supported 也只列了這一項。我試著送 plain 過去,直接被退回來:

error=invalid_request
error_description=Invalid+code_challenge_method.+Must+be+S256.

用 crypto.subtle 手刻

瀏覽器內建的 Web Crypto API 就夠用了,不需要任何函式庫。新增 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 }
}

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

有幾個地方值得說清楚。

crypto.getRandomValues 不能換成 Math.random 後者是給你決定圖片輪播順序用的,產出可以被預測。這裡要的是猜不到,不是看起來很亂。

base64url 的長度拆解圖,標題「32 個位元組,剛好 43 個字元」,副標「base64url 只是把三個字元換掉」。上半部是由左到右三段的流程。最左邊是淺藍底方塊,大字 32,下面小字寫「位元組亂數」。往右一條深藍色實心箭頭,箭頭上方分兩行標著「每 3 個換 4 個」,指向中間的白底細框方塊,大字 44,下面一行寫「base64 字元」,再下面一行寫「末尾一個 = 補位」,其中的等號是珊瑚色的小方塊。再往右一條深藍色實心箭頭,箭頭上方分兩行標著「砍掉補位」,指向整張圖最大的深藍色實心方塊,白色大字 43,下面一行寫「code_verifier(答案)的字元數」。中間是一張白色長條卡片,左上角灰字寫「base64url 換掉這三個,網址裡才不用跳脫」,卡片內三組並排的替換:淺藍方塊的加號指向深藍方塊的半形減號、淺藍方塊的斜線指向深藍方塊的底線、珊瑚色方塊的等號指向珊瑚色字的「直接刪掉」。下半部是一條長度範圍軸,上方灰字寫「RFC 7636 規定 code_verifier(答案)的長度範圍」,軸的左端是深藍色刻度、右端是灰色刻度,左端下方寫「43 下限,32 位元組正好踩在這裡」,右端下方寫 128。圖最下方一行灰字註記寫著火線超人的 SecureRandom.urlsafe_base64(32) 產出的也是 43 個字元,同一個算法

那三行 replace 做的就是這件事。

crypto.subtle 只在安全環境下存在。 HTTPS 頁面有,http://localhost 也有,因為瀏覽器把 localhost 當成安全環境。但如果你為了用手機測試而改開 http://192.168.x.x:5173crypto.subtle 會變成 undefined,錯誤訊息是「Cannot read properties of undefined」,看起來完全不像跟協定有關。

digest() 回的是 ArrayBuffer,不是可以直接跑迴圈的東西,所以要先包一層 new Uint8Array(digest) 才餵得進 String.fromCharCode

答案要放在哪裡

現在回到開場那個問題。

createPkcePair() 產生的那對值,在你把使用者送去授權伺服器的那一刻就會消失,因為頁面整個被換掉了。等使用者帶著 code 回來,你的 JavaScript 是重新執行的,記憶體裡什麼都沒有。

所以 code_verifier(答案)一定要存到某個地方去。瀏覽器裡的選項大致三個:

放哪裡 活多久 適不適合
一般變數 換頁就沒了 不行,跳轉回來就掉了
localStorage 一直留著,跨分頁共用 過頭了,用完的答案還躺在那
sessionStorage 同一個分頁,關掉就沒 剛好,授權本來就是一個分頁內的事

所以 pkce.js 再加兩個函式:

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
}

takePkceVerifier 故意不叫 get。取出來就順手刪掉,因為它的任務只有一次,留著只是多一個會外洩的東西。火線超人也是同一個原則,換完 token 之後那筆狀態記錄就 consume! 掉,而且建立時就寫死 10 分鐘到期。

至於它為什麼存資料庫而不是 sessionStorage:火線超人的授權是從 LINE 裡點開的,使用者被送回來時進的是伺服器的 callback,根本沒有哪個瀏覽器分頁一路跟著。存放的位置要選在「發起授權的人」和「接住 callback 的人」都摸得到的地方,這件事 day09 還會再碰一次。

少一樣會怎樣

我把參數逐個拿掉在這台 sandbox 上試了一輪:

情境 結果
authorize 完全不帶 code_challenge(題目) 照樣拿到 code,換 token 也不用 verifier
code_challenge_method=plain 直接退回,Must be S256
送了題目,換 token 少 code_verifier(答案) HTTP 400,Missing code_verifier parameter
帶錯的 code_verifier(答案) HTTP 401,Invalid grant or Invalid PKCE Verifier

第一列要特別講。這台 sandbox 不強制 PKCE,不帶就是不驗。所以 PKCE 不是伺服器逼你用的,是你自己該用的,一旦你送了 code_challenge(題目),它就會認真比對。

還有一件事必須據實說:這台沒有做 code 的單次使用限制,同一串 code 我換了兩次 token,兩次都成功。標準要求 code 用過即失效,這台的 code 是一個自己簽的 JWT,5 分鐘內都認。所以前面講的「攻擊者搶先換走 token」在這台上其實兩邊都換得到,你在真實伺服器上不會看到這種行為。

跟著做:手刻 verifier 與 challenge,再故意答錯一次

起點:day06 之後的 smart-app,day07 沒有動到任何檔案。手邊還要有 day07 第三步組好的那串 authorize URL。

產出:多一個 pkce.js,而且你會親眼看到答案對不上就換不到 token。這個檔案明天會被 auth.js 直接拿去用。

第一步,寫 pkce.js

把上面兩段程式碼合成一個 pkce.js,放在 app.js 旁邊。app.js 今天不用動。

第二步,在 console 驗證

啟動 python3 -m http.server 5173,開 http://localhost:5173,按 F12。因為專案是 ES module,console 可以直接載入剛寫好的檔案:

const { createPkcePair, savePkceVerifier } = await import('./pkce.js')
const { verifier, challenge } = await createPkcePair()

// 自己再算一次 SHA-256 對照
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
const again = btoa(String.fromCharCode(...new Uint8Array(digest)))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

console.log(verifier.length, challenge === again)  // 43 true
savePkceVerifier(verifier)
console.log(challenge)  // 等一下要用,先複製起來

第三步,帶著題目走一次授權

把昨天那串 authorize URL 拿出來,尾巴接上兩個參數,{challenge} 換成剛剛印出來的值:

&code_challenge={challenge}&code_challenge_method=S256

貼進同一個分頁的網址列,走完 Login 與 Approve。這一步不能開新分頁,sessionStorage 是分頁綁定的,換分頁就找不到答案了。

第四步,用三種答案各換一次

回到 http://localhost:5173/?code=… 之後,在 console 貼上:

const { takePkceVerifier } = await import('./pkce.js')
const code = new URLSearchParams(location.search).get('code')
const verifier = takePkceVerifier()   // 跨過跳轉還在
const TOKEN = '你的 token 端點'

const post = (extra) => fetch(TOKEN, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: 'http://localhost:5173/',
    client_id: 'my-smart-app',
    ...extra,
  }),
}).then(async (r) => console.log(r.status, await r.text()))

await post({})                                    // 不帶
await post({ code_verifier: 'wrong-verifier-1234567890-1234567890-abcdef' })  // 帶錯的
await post({ code_verifier: verifier })           // 帶對的

預期結果verifier 在跳轉之後還取得回來,長度 43。三次呼叫依序是 400(Missing code_verifier parameter)、401(Invalid grant or Invalid PKCE Verifier,訊息裡還會把它算出來的值和當初收到的擺在一起給你看)、200 並回傳完整的 token response。

執行結果圖,標題「三種答案的樣子」,副標「同一串 code,差別只在 code_verifier」。整張是一個白色的 Console 面板,畫面上有三個綠色圓形編號,與圖片下方三條註記一一對應。最上面一列左緣掛著編號 1,灰字說明三次都送同樣的 grant_type=authorization_code,接著 code= 後面是八個淺灰圓點,右邊一行更小的灰字寫「每次授權都不同」,下一行接 redirect_uri=http://localhost:5173/ 與 client_id=my-smart-app。下面三列,每列左邊是這次送的 code_verifier、中間是狀態碼、右邊是回應內容,三個狀態碼垂直對齊。第一列左邊灰字寫「不帶 code_verifier」,狀態碼 400 為暗紅色,回應是 invalid_request 與 Missing code_verifier parameter。第二列左緣掛著編號 2,以珊瑚色寫著送出錯的答案 wrong-verifier-…,狀態碼 401 為暗紅色,回應是 invalid_grant 與 Invalid grant or Invalid PKCE Verifier,後面接兩個被截斷的值 bq3fsseb… 與 Xa_Pp_hI…。第三列左緣掛著編號 3,以珊瑚色寫著由 takePkceVerifier 取回的正確答案,狀態碼 200 為綠色,回應是 access_token、patient、scope、refresh_token。最下方三條綠色編號註記:三次的 code 是同一串,只有 code_verifier 在變,所以擋掉你的就是它;401 的前一串是 sha256 你送的答案,固定字串所以人人相同,後一串是你送的題目,人人不同;第三次的答案是從 sessionStorage 取回來的,它跨過了整趟跳轉

401 那行值得多看一眼。前面那串 bq3fsseb… 是伺服器拿你送的答案算出來的,而上面那個錯誤答案是我寫死的固定字串,所以你跑出來會是同一個值,可以直接對照。後面那串是你當初送出去的題目,由亂數算出,每個人都不一樣。

同一串 code 換得到兩次 token,是這台 sandbox 沒做單次使用限制,不是標準行為。

小結

PKCE 補的是 public client 的先天不足:沒有 client_secret 可以證明身分,那就臨時生一組,把 code_verifier(答案)留在自己手上,只把 code_challenge(題目)送出門。

程式碼其實只有幾行,getRandomValuescrypto.subtle.digest 加 base64url 轉換。真正要想清楚的是那個答案放哪裡,因為它必須橫跨一次完整的頁面跳轉。我們選 sessionStorage 並且用完就刪,火線超人選資料庫加 10 分鐘到期,選擇不同是因為兩邊接住 callback 的人不一樣。

到這裡,authorize URL 上的參數其實都湊齊了。discovery 給了端點、PKCE 給了題目,剩下的都是固定填法。

明天把這些接起來寫成程式,讓 app 自己走完整趟授權,並且把 state 那個一直被我隨便填成 abc123 的參數認真處理掉。


上一篇
Day07 - OAuth 圖解授權碼流程
下一篇
Day09 - 從頭跑完一次授權
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言