為什麼選 LINE
前面二十五天都在講技術。今天講一個技術解決不了的問題:你的 app 做好了,病人怎麼打開它?
這個問題比想像中嚴重。你可以把 SMART 授權寫得完美無缺,把跨院合併做得漂漂亮亮。然後你會發現沒有人會為了看健康資料去裝一個 App。
火線超人的答案是 LINE。當初決定的時候,寫在提案文件裡的話是這樣:
選擇 LINE 作為載體,不是因為技術考量,而是因為這是民眾最熟悉、最常用的溝通工具。
通路選擇不是技術決策。
免安裝才是重點
LINE 帶來的最大好處,不是它的 API 有多好用,而是使用者不用再裝一個 App。
想像一下你要跟長輩解釋。

這個流程你講到第三句,長輩就放棄了。
推廣的想法也是依照免安裝的方向走,但我們不是要推 LINE。而是大部分人手機裡都有 LINE,拿它做媒介來推 FHIR 在台灣的應用再適合不過了。LINE 只是手段,不是目的。
LIFF 是什麼
day17 提過 LIFF,簡單說,其實就是在 LINE 裡面開了一個網頁。我們今天就來說一下,它的全名很容易被誤解。
LIFF 的全名是 LINE Front-end Framework。名字裡有 Framework,會讓人以為要學一套新的前端寫法。其實不是,你原來怎麼寫網頁,在 LIFF 裡還是怎麼寫。差別只在可以拿到使用者的 LINE user id 而已。
為什麼要開一個網頁?因為 LINE 原本能發的訊息做不到表單互動和相機掃描。純文字當然不用說。Flex Message 那種排版好的卡片也只能點按鈕,沒辦法讓人填欄位。病人要輸入生日綁定、要掃 QR code,這些事必須要有一個網頁才有辦法做。
所以 LIFF 的角色很單純:提供一個 LINE user id,加一個網頁容器。就這兩件事。
一個入口,多個頁面
實作上第一個決定,是只註冊一個 LIFF endpoint。
LINE Developers Console 上每註冊一個 LIFF app,就多一組設定要維護。十個頁面就是十組設定,每一組的 endpoint 網址都要各自對好。
做法是用網址的路徑決定要跑後端的哪一段程式。https://liff.line.me/<liffId>/bind_patient?server=<alias> 會開到應用的 /liff/bind_patient?server=<alias>。
新增一頁只要在後端加一段處理程式跟一個畫面,不用再去 console 註冊。
登入完不會回到原本那一頁
這是單一入口那個設計會踩到的地方,而且錯誤訊息完全不會告訴你原因。
先把兩個路徑分清楚。你在 console 註冊的 endpoint 是 /liff,使用者實際要去的頁是 /liff/bind_patient。
順著時間走一次。使用者從 LINE 裡點開連結,開的是 /liff/bind_patient。這一頁需要身分,所以 SDK 呼叫 liff.login()。使用者在 LINE 那邊登入完,瀏覽器被導回來。
問題就在這一步:導回來的是 /liff,不是 /liff/bind_patient。
liff.login() 沒有特別指定的話,會導回你註冊的那個 endpoint。網址後面接著 OAuth 的 callback 參數。
原本要去的那一頁沒有丟。那一頁在網址上一個叫 liff.state 的參數裡,是 LIFF 平台幫你放的。
所以 /liff 這條路徑不能是空的。這一頁要做兩件事。一是載入 LIFF SDK(LINE 官方那包 JS)。SDK 讀 callback 參數把登入完成。二是讀 liff.state,把使用者送去那個路徑。
如果你只寫了 /liff/bind_patient 而沒寫 /liff,使用者登入完會撞到路由錯誤。而且使用者完全不知道發生什麼事。
身分要後端驗,不能信前端
這一段是整篇最重要的技術決定。
LIFF 頁面可以拿到 LINE user id。很自然的寫法是前端拿到之後送給後端,後端就用它。
絕對不要這樣做。
前端送上來的每一個值,使用者都可以自己改。把 user id 直接當身分用,等於任何人改一個字串就能存取別人的健康資料。
正確做法是前端送 LIFF 的 ID token 上來,後端自己去驗。兩種寫法擺在一起是這樣:

順帶一提,頁面要分成兩種。一種需要身分,使用者沒登入就觸發登入流程。另一種是公開頁,只初始化 SDK 不觸發登入。ID token 也不外露。
還有一個小地方:LIFF id 沒設定的時候要直接把錯誤顯示出來。不要沒有任何提示就把頁面畫出來,看起來一切正常,點什麼都沒反應。
開在 LINE 裡還是瀏覽器裡
LIFF 頁面可能開在 LINE 的內建瀏覽器裡,也可能開在系統的外部瀏覽器裡。而且你不能假設是哪一種。
處理方式是直接問,不要用猜的。要用掃描功能,就用 liff.isApiAvailable("scanCodeV2") 問這個功能在不在。不要因為人在 LINE 裡就假設掃描能用。真的需要知道在不在 LINE 裡,才用 liff.isInClient()。
能用原生掃描就用,不能就降級成手動輸入。
這裡還有一個平台細節。iOS 上要用 scanCodeV2,LIFF 的 view size 必須設成 Full。設成別的就是不能用,而且不會有任何訊息說明原因。
SMART 那一層完全不用動
上面那幾節講的單一入口、ID token 驗證、能力偵測,全部都在外殼這一層。外殼就是使用者看得到的那一層。在火線超人,外殼是 LIFF 頁面,也就是 LINE 裡開起來的那個網頁。
另一層是 SMART 授權、FHIR 查詢、病人上下文解析。這一層在火線超人是 Rails 後端在做,跟 LINE 一點關係都沒有。
換掉外殼會動到什麼?就是前面講 LIFF 的那幾節。SMART 那一層一行都不用改。
這不是理論。我們來做個測試。
換一個外殼,授權還走得動嗎
前面說 SMART 那一層不在意外殼。這種話講起來很輕鬆,所以我去測了一次。
測法是把第三幕那支 app 原封不動塞進一個 <iframe>。iframe 是這支 app 從沒見過的外殼。app 被裝在別人的頁面裡,網址列不是自己的。如果換外殼真的無所謂,授權流程應該照走。
先講一件事,免得你跟著做。這個測試的最後一步會被擋,原因跟 SMART 無關。你看結果就好。
架法很簡單,兩台本機靜態伺服器。一台在 localhost:5177 跑那支 app。另一台在 localhost:5180,那一頁只有一個 <iframe> 把 app 嵌進去。
還有一個背景要先說,不然結果會看不懂。授權途中會離開本機。點下「連線到 A 醫院」之後,iframe 裡開的是 launcher 的登入頁。launcher 在 launch.smarthealthit.org,那是一個公開網域。同意之後 launcher 要把你導回 localhost:5177。
所以整條路是:本機出發,中間到公開網域,最後導回本機。

前五步全部成立。app 在 iframe 裡正常渲染,launcher 的登入頁和同意畫面都出得來。按下 Approve 之後,授權碼也發出來了。
第六步是瀏覽器要跟著 302 導回 localhost:5177,它拒絕了。
這裡要看清楚是誰擋的。授權伺服器已經把 302 送出來了,Location 上帶著授權碼。Chrome 有一道本機網路檢查,不讓公開網域的頁面把 iframe 導向本機位址。這裡正好是 launch.smarthealthit.org 要導向 localhost:5177。
這道檢查看的是來源跟目的地,公開網域連向本機。它不看你用哪個函式庫,也不管你在不在 SMART 的流程裡。
為了確認不是 app 自己有問題,我們跑一個對照組來看看。同一支 app、同一個 redirect_uri,這次不放進 iframe,直接開一個瀏覽器分頁跑。登入、按下同意之後順利導回,病人資料、病況、用藥全部載入。
兩次的差別只有在不在 iframe 裡。所以擋下來的是 iframe 加本機這個組合,不是 app 寫錯。
那這個測試到底證明了什麼?授權流程在 iframe 裡從頭走到授權碼發出,一步都沒有少。 SMART 那一層從頭到尾沒有察覺自己被裝在別人的頁面裡。
這個失敗只會在自己電腦上跑的時候出現。app 真的上線之後,redirect_uri 就不是本機位址了,這道檢查不會擋。不過你跟著做的時候就是在自己電腦上,所以你跑起來也會被擋在這一步。
原始碼裡早就有處理 iframe
上面那個測試證明的是流程沒有少走一步。還有一個更直接的證據,在 fhirclient 的原始碼裡。
授權的時候 console 出現一則警告:
Your app is being authorized from within an iframe or popup window. Please be explicit and provide a "completeInTarget" option. Use "true" to complete the authorization in the same window, or "false" to try to complete it in the parent or the opener window.
翻原始碼可以看到 fhirclient 做了什麼。它先判斷目前是不是跑在 iframe 或 popup 裡,再看呼叫端有沒有給 completeInTarget。沒給的時候 fhirclient 就自己補一個值。在 iframe 裡補 true,不在就補 false。補完印出那則警告。
true 的意思是授權在 iframe 內收尾。這正好對上我看到的現象:被導走的只有 iframe,外層頁面沒被整頁換掉。
給 false 會走另一條路,授權改成請外層頁面接手。ready() 用 postMessage 通知外層頁面,然後把自己停在那裡等。外層頁面不接手,流程就一直停著。
這就是「一行都不用改」的真正意思。 fhirclient 不管你用 LINE、iframe 還是原生 App。fhirclient 只要你回答一件事:授權在哪裡結束。
跟著做:把畫面改掉,看 app 還跑不跑
前面說換掉外殼 SMART 那一層不用動。這件事在你自己的專案裡五分鐘就驗得出來。
打開第三幕那個專案的 index.html,把版面改掉。換個顏色、把區塊順序搬一搬、把病況那塊改成別的樣子,都可以。
只有一條規則:有 id 的那些元素不要刪掉。
存檔,重新整理,跑一次授權。
授權照樣走完,病人資料、病況、用藥照樣進來,只是長得不一樣了。
現在回頭看你剛剛動了什麼。只有 index.html。servers.js 沒碰,app.js 裡 FHIR.oauth2 那幾行沒碰,loadConditions 那些讀資料的也沒碰。
那些就是 SMART 那一層。你剛剛換掉的畫面,就是外殼。
在火線超人,SMART 那一層在 Rails 後端。在你這支 app,它們就在瀏覽器裡。位置不同,是同一層。
順帶解釋剛剛那條規則。app.js 用 document.querySelector('#conditions') 這種寫法去找畫面上的位置,再把資料塞進去。那些 id 就是兩層中間的連接點。刪掉的話 app.js 找不到位置,資料就進不來。
換掉外殼要動哪些,整理出來是這樣:

這個練習證明的是同一支網頁 app 可以換版面。換成 LIFF 也差不多,因為 LIFF 就是網頁。index.html 換成 LIFF 的 SDK 初始化加你的頁面就好。
換成原生 App 就不只這樣了。原生沒有 DOM,document.querySelector 那些完全用不上,fhirclient 也要換成該平台的函式庫。但要重寫的還是同一批:怎麼把資料變成畫面。servers.js 記的端點、scope、client id,換到哪個平台都是同一份。
第二件事順便看:找出你的 app 裡「決定身分」的那一行。
在第三幕的版本裡那是 client.patient.id,值從 token 裡來。
重點在這裡:它不是從畫面來的。 你剛剛把畫面整個換掉,這一行還是從 token 拿身分,來源沒有變。
前面講 LIFF 那節說身分要後端驗,講的是同一件事:身分不從畫面來。你在第三幕已經做對了,只是那時候還沒有一個對照組讓你看出來。
小結
通路選擇不是技術決策,是「你的使用者已經在哪裡」的決策。台灣的答案是 LINE,免安裝是關鍵。
技術上 LIFF 只提供兩樣東西:一個 LINE user id 和一個網頁容器。身分要在後端驗,前端送上來的 user id 不能信。
而 SMART 那一層,實測證明它對外殼是中立的。它唯一要求的是你講清楚授權在哪個視窗收尾。
明天是第四幕最後一篇。把安全整理成一張清單,然後拿它稽核你自己那個 app。