圖解授權碼流程
火線超人處理授權的程式碼切成兩塊,一塊在 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 端點發請求,全程不經過網址列。
攤開來就是這樣:

火線超人那條分界線就在這裡。AuthController 站在前台,處理的是網址列帶回來的東西;FhirOauthService 站在後台,負責換 token。
順帶一提,這台 sandbox 的 discovery 裡 response_types_supported 還列著 token 和 id_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

所以你的 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-app,app.js 頂端的 FHIR_BASE_URL 已經換成 Launcher 的 Server's FHIR Base URL,console 會印出 authorize 端點。
產出:親眼看到同意畫面,以及網址列上跑回來的 code 和 state。專案檔案一個字都不用改。第三步組好的那串 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,會依序看到:
- Patient Login:挑一位病人。密碼欄已經預填好,畫面上也寫著任何密碼都會通過
- Authorize App Launch:同意畫面,列出這個 app 要的權限,包括「Read all data about the selected patient」和一句「可以持續存取直到你收回授權」。按 Approve
然後你會被送回 http://localhost:5173/,網址列變成:
http://localhost:5173/?code=eyJhbGciOiJIUzI1NiIs…&state=abc123
code 到手了,state 也原樣回來了。整趟沒有寫任何程式。

第五步,弄壞它一次
回去把網址裡的 &aud=… 整段刪掉,再貼一次。這次不會有登入畫面,直接被送回來:
http://localhost:5173/?error=invalid_request&error_description=Missing+aud+parameter&state=abc123
預期結果:完整的那一次網址列上有 code 和 state;拿掉 aud 那一次換成 error 和 error_description,而 state 兩次都回來了。
順便看一眼那串 code 有多長。它是這台 sandbox 自己簽的 JWT,把 clientid、redirecturi、scope 全包在裡面,所以它不必在後端存任何狀態就能驗證。這是它的實作細節,SMART 沒有規定 code 長什麼樣,對你的 app 而言它就是一串不透明的字。
小結
授權碼流程之所以要跳兩趟,是因為它把三件事分開了:密碼只給授權伺服器、兌換券走看得見的前台、token 走看不見的後台。
每個參數都對應一個具體的擔憂。redirect_uri 綁住 code 的去向,aud 綁住 token 的用途,state 綁住這次 callback 是不是你發起的。少一個,流程還是會跑,但少擋一種狀況。
火線超人把 AuthController 和 FhirOauthService 拆開,拆的其實就是這兩條通道。你今天手動走完的第一趟,明天開始會變成程式碼。
只是剛才那張兌換券不認人。它躺在網址列上,任何拿到它的人都能換到 token,因為換 token 這一趟不需要出示任何 secret。
明天講 PKCE,看純前端的 app 在沒有 client_secret 的情況下,怎麼證明「這張 code 是我的」。