下拉重新整理

圖解授權碼流程

3,464 字 9 分鐘閱讀 9 次閱讀

火線超人處理授權的程式碼切成兩塊,一塊在 AuthController,一塊在 FhirOauthService

AuthController#fhir_callback 的開頭是這樣:

if params[:error].present?
  # 授權伺服器把錯誤送回來了
end

unless state && session[:fhir_oauth_state] == state
  Rails.logger.warn("[FhirCallback][#{request_id}] CSRF attack detected: state mismatch")
  render status: :bad_request, json: { error: 'invalid_state' }
  return
end

unless code
  # 沒有 code 就沒得換
end

看網址列有沒有帶錯誤回來、比對 state、確認 code 在不在。三件事的共同點是,它們檢查的都是瀏覽器送過來的東西

再看 FhirOauthService#complete_authorization 那一側:

token_response = exchange_code(
  oauth2_service: oauth2_service,
  code: code,
  callback_url: callback_url,
  code_verifier: oauth_state.code_verifier
)

拿 code 去換 token。這一趟瀏覽器完全沒有參與,是伺服器自己對授權伺服器發的請求。

這條分界線不是為了讓程式碼好看才畫的。授權碼流程本來就有兩條通道,一條經過瀏覽器,一條不經過,controller 和 service 只是各自站在一條上面。

今天就把這兩條通道攤開。

你不該碰到那組密碼

先講一件很基本、但決定了整個流程長相的事:你的 app 不該拿到使用者在醫院的帳號密碼

聽起來像廢話,但如果沒有這條限制,事情簡單得多:使用者在你的畫面上輸入帳密,你拿去跟 FHIR 伺服器換一張 token,一趟就結束。問題是這樣一來,帳密經過了你的程式碼、你的記憶體、可能還有你的日誌檔。使用者要相信的不只是醫院,還包括每一個他授權過的 app。

OAuth 的解法是把輸入密碼那一步搬走。使用者被送到授權伺服器自己的頁面上登入,你的 app 從頭到尾看不到那個畫面裡發生什麼事,只會在結束後收到一個結果。

所以才要跳轉。跳轉不是技術限制,是刻意的隔離。

兩條通道

那結果怎麼送回來?這裡有個取捨。

最直接的作法是授權伺服器直接把 token 放在轉址回來的網址上。但網址列是個很吵的地方:它會進瀏覽器歷史紀錄、可能被當成 referrer 送給下一個網站、會被使用者複製貼上、也常常整串寫進伺服器的存取日誌。一張能讀病歷的 token 躺在那裡太久,遲早會流出去。

授權碼流程的作法是:網址列上只放一張兌換券,也就是 code。它短命(這台 sandbox 給 5 分鐘),而且單獨拿著沒用。規範上它還該用過即失效,不過這台沒擋,同一張 code 我換了兩次都回 200。真正的 token 要另外走一趟,由你的程式直接對 token 端點發請求,全程不經過網址列。

攤開來就是這樣:

兩條通道的對照圖,標題「兌換券走前台,token 走後台」,副標「看得見的那條只放 code」。上半部是一塊白底虛線框的區域,標題為前台通道,右上角註明瀏覽器網址列誰都看得到。區域內左側是白底深藍邊框的方塊「你的 app」,右側是淺藍底的「授權伺服器」,兩者之間有兩條訊息:上面一條向右,標籤為 GET authorize,下方列出 client_id、scope、aud、state、code_challenge;下面一條向左,標籤為 302 回 redirect_uri,下方是用珊瑚色標示並加底線的 code 以及 state。區域底部一列標籤寫著「這一串會留在」瀏覽器歷史、referrer、伺服器日誌、複製貼上。下半部是一塊深藍色實心區域,標題為後台通道,右上角註明你的程式自己發的 POST 不經過網址列。同樣左側是白底的「你的 app」、右側是「授權伺服器」,兩條訊息:向右一條標籤為 POST token,下方列出 code、redirect_uri、client_id、code_verifier;向左一條標籤為 200,下方是白色粗體的 access_token 以及 patient、expires_in 3600。圖最下方註記寫著網址列上只放得到 code,5 分鐘失效,access_token 從頭到尾沒上過網址列

火線超人那條分界線就在這裡。AuthController 站在前台,處理的是網址列帶回來的東西;FhirOauthService 站在後台,負責換 token。

順帶一提,這台 sandbox 的 discovery 裡 response_types_supported 還列著 tokenid_token,那是舊的 implicit 流程,也就是「直接把 token 放網址列」的那種。SMART v2 已經不走這條,你只會用到 code

authorize URL 上的每個參數

第一趟的網址長這樣:

{authorization_endpoint}
  ?response_type=code
  &client_id=my-smart-app
  &scope=launch/patient patient/*.rs openid fhirUser offline_access
  &redirect_uri=http://localhost:5173/
  &aud={你的 FHIR base URL}
  &state=abc123

一個一個看它在做什麼,以及少了會怎樣。這幾行不是我猜的,是把參數一個個拿掉之後,這台 sandbox 真的回給我的:

參數 在講什麼 拿掉會怎樣
response_type 我要的是兌換券,不是 token 拿不到 code
client_id 我是誰 真實伺服器會擋,這台不檢查
scope 我想要哪些權限 day10 和 day11 會講
redirect_uri 完事之後把人送回哪裡 HTTP 400,直接停在授權端點
aud 這張票要拿去用在哪台 FHIR 伺服器 轉址回來帶 Missing aud parameter
state 我自己塞的一張標籤,原樣還我 這台照跑,但你就防不了 CSRF,day09 講

redirect_uri 那一列值得停一下。少了它不是轉址回來告訴你哪裡錯,而是直接回 400 停在原地。原因很單純:授權伺服器不知道該把錯誤送去哪裡。它只有在知道你家地址的前提下,才有辦法把壞消息送回去。

這也是為什麼下面這個行為看起來怪,其實很合理。

錯誤是「成功地告訴你失敗」

aud 拿掉再跑一次,授權伺服器回的是 HTTP 302,一個轉址:

http://localhost:5173/?error=invalid_request
  &error_description=Missing+aud+parameter
  &state=abc123

兩者對照圖,標題「少 aud 轉址回來,少 redirect_uri 停在原地」,副標「差別在授權伺服器知不知道你家地址」。畫面分成左右兩張等高的卡片。左卡是白底加淺色細框,標頭有一個往左下轉的箭頭圖示,寫著「少 aud」,右上角灰色小字註明「知道要送去哪」。卡片上半是一條由左到右的流程,最左邊是淺藍底圓角方塊「授權伺服器」,往右是一條灰色實線箭頭,線上壓著一顆淺藍底深色字的標籤 302,箭頭指進右邊的白底深藍外框方塊,方塊內上行寫「你的 app」,下行以灰色小字寫 redirect_uri。分隔線之下是三組欄位,每組上行是灰色小字的欄位名,下行是值,error 對 invalid_request,error_description 對 Missing aud parameter,state 對「原樣回傳」。右卡是深藍實心底,標頭有一個被斜線劃掉的地圖定位圖示,白字寫著「少 redirect_uri」,右上角灰藍小字註明「不知道要送去哪」。卡片上半的排列與左卡對齊,最左邊是較淺的深藍方塊「授權伺服器」,往右一條實線,線上壓著一顆珊瑚色底白字的標籤 400,這條線沒有箭頭,末端接到一個珊瑚色的禁止符號就停住,符號右側白字寫「不轉址」,下方灰藍小字分兩行寫「直接停在授權端點」。分隔線之下是灰藍小字標題 HTTP 400 body,接著三行白色等寬字的 JSON 回應,欄位 error 是 invalid_request,欄位 error_description 是 Missing redirect_uri parameter。圖最下方一行灰字註記寫著「能轉址的錯誤就轉址回去,連地址都沒有才停在原地」

所以你的 callback 不能寫成「網址上有 code 就換 token」,得先問「這次是不是帶著錯誤回來的」。火線超人 fhir_callback 開頭第一段檢查的就是這個,順序不能顛倒。

換 token 那一趟

拿到 code 之後,第二趟是一個 POST:

POST {token_endpoint}
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code={剛剛拿到的}
&redirect_uri=http://localhost:5173/
&client_id=my-smart-app

redirect_uri 在這裡出現第二次,很多人第一次看到會覺得多餘:都已經轉址回來了,還送它幹嘛?

它在這一趟的角色不是「送我回哪裡」,是證明這張 code 是發給我的。授權伺服器發 code 的時候把 redirect_uri 記了下來,換 token 時會比對,兩者不符就拒絕。我實際試過,換一個埠號送出去,回的是 401 加上 Invalid redirect_uri parameter

回來的東西就是這一幕的目標:

{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "launch/patient patient/*.rs openid fhirUser offline_access",
  "patient": "018f428e-34f6-4707-8009-5ad742f901e7",
  "refresh_token": "eyJhbGciOi…"
}

access_token 是接下來每一次 FHIR 請求要掛在 Authorization header 上的東西;patient 是授權伺服器順手告訴你「這次是在看誰」,day12 專門講它;refresh_token 留到 day14。

少了一樣東西

傳統的 OAuth 還有一個角色沒出場:client_secret。伺服器端的 app 在換 token 時會連 secret 一起送,證明自己真的是註冊過的那個 client。

但我們的 app 整包會被瀏覽器下載,app.js 按 F12 就看得到。secret 放進去等於公開,所以純前端的 app 一律是 public client,沒有 secret 可用。

於是問題來了:如果 code 就這樣躺在網址列上,而換 token 又不需要任何 secret,那這張兌換券等於不認人,誰撿到誰就能換走 token。

這個洞明天補。

跟著做:自己組一次授權網址

今天不寫程式,只用網址列。

起點:day06 之後的 smart-appapp.js 頂端的 FHIR_BASE_URL 已經換成 Launcher 的 Server's FHIR Base URL,console 會印出 authorize 端點。

產出:親眼看到同意畫面,以及網址列上跑回來的 codestate。專案檔案一個字都不用改。第三步組好的那串 authorize URL 要留著,明天會在尾巴接上兩個參數。

第一步,把伺服器開著

python3 -m http.server 5173

授權完成後瀏覽器會被送回 http://localhost:5173/,沒東西接的話會出現連線失敗。頁面內容不重要,重點是網址列。

第二步,抄下 authorize 端點

http://localhost:5173,按 F12,從 console 複製 day06 印出來的那一行 authorize 端點。它中間夾著 /sim/ 那段編碼,別自己拼。

第三步,把參數接上去

在編輯器裡把下面這串接成一行,{authorize} 換成剛剛複製的端點,{aud} 換成你的 FHIR_BASE_URL

{authorize}?response_type=code&client_id=my-smart-app&scope=launch%2Fpatient%20patient%2F*.rs%20openid%20fhirUser%20offline_access&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F&aud={aud}&state=abc123

aud 裡有 :/,最保險的作法是丟進瀏覽器 console 用 encodeURIComponent() 轉一次再貼上。client_id 這台不檢查,填什麼都收;state 今天也隨便填,abc123 就好。

第四步,貼進網址列

按下 Enter,會依序看到:

  1. Patient Login:挑一位病人。密碼欄已經預填好,畫面上也寫著任何密碼都會通過
  2. Authorize App Launch:同意畫面,列出這個 app 要的權限,包括「Read all data about the selected patient」和一句「可以持續存取直到你收回授權」。按 Approve

然後你會被送回 http://localhost:5173/,網址列變成:

http://localhost:5173/?code=eyJhbGciOiJIUzI1NiIs…&state=abc123

code 到手了,state 也原樣回來了。整趟沒有寫任何程式。

執行結果圖,標題「code 回來的樣子」,副標「貼完網址、按完 Approve,就長在網址列上」。畫面上有三個綠色圓形編號,與圖片下方三條註記一一對應。上方是一個白色瀏覽器視窗,只畫出網址列那一條,右上角掛著編號 1,網址列內容是 http://localhost:5173/ 之後接一段淺珊瑚底色的 code 參數,參數值以刪節號帶過沒有寫出來,再接灰色的 &state=abc123。視窗下方是「拆開看,網址列上就這三段」,三列各是一段網址加一句說明。第一列 http://localhost:5173/ 是你送出去的 redirect_uri,人就是被送回這裡。第二列左緣掛著編號 2,以珊瑚色寫出 code 那一段,值同樣以刪節號帶過,說明是兌換券,單獨拿著沒用,要另外走一趟才換得到 token。第三列左緣掛著編號 3,內容是 &state=abc123,說明是你自己塞的那張標籤,授權伺服器原樣還你。最下方三條綠色編號註記:這一整串會進瀏覽器歷史,所以網址列上只放得到 code,放不得 token;code 要按過 Approve 才會出現,網址列上有它就代表使用者同意了;state 今天先隨便填一個值,day09 會把它認真處理掉

第五步,弄壞它一次

回去把網址裡的 &aud=… 整段刪掉,再貼一次。這次不會有登入畫面,直接被送回來:

http://localhost:5173/?error=invalid_request&error_description=Missing+aud+parameter&state=abc123

預期結果:完整的那一次網址列上有 codestate;拿掉 aud 那一次換成 errorerror_description,而 state 兩次都回來了。

順便看一眼那串 code 有多長。它是這台 sandbox 自己簽的 JWT,把 clientid、redirecturi、scope 全包在裡面,所以它不必在後端存任何狀態就能驗證。這是它的實作細節,SMART 沒有規定 code 長什麼樣,對你的 app 而言它就是一串不透明的字。

小結

授權碼流程之所以要跳兩趟,是因為它把三件事分開了:密碼只給授權伺服器、兌換券走看得見的前台、token 走看不見的後台。

每個參數都對應一個具體的擔憂。redirect_uri 綁住 code 的去向,aud 綁住 token 的用途,state 綁住這次 callback 是不是你發起的。少一個,流程還是會跑,但少擋一種狀況。

火線超人把 AuthControllerFhirOauthService 拆開,拆的其實就是這兩條通道。你今天手動走完的第一趟,明天開始會變成程式碼。

只是剛才那張兌換券不認人。它躺在網址列上,任何拿到它的人都能換到 token,因為換 token 這一趟不需要出示任何 secret。

明天講 PKCE,看純前端的 app 在沒有 client_secret 的情況下,怎麼證明「這張 code 是我的」。