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

這個流程你講到第三句,長輩就放棄了。
推廣的想法也是依照免安裝的方向走,但我們不是要推 LINE。而是大部分人手機裡都有 LINE,拿它做媒介來推 FHIR 在台灣的應用再適合不過了。LINE 只是手段,不是目的。
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 沒設定的時候要直接把錯誤顯示出來。不要沒有任何提示就把頁面畫出來,看起來一切正常,點什麼都沒反應。
LIFF 頁面可能開在 LINE 的內建瀏覽器裡,也可能開在系統的外部瀏覽器裡。而且你不能假設是哪一種。
處理方式是直接問,不要用猜的。要用掃描功能,就用 liff.isApiAvailable("scanCodeV2") 問這個功能在不在。不要因為人在 LINE 裡就假設掃描能用。真的需要知道在不在 LINE 裡,才用 liff.isInClient()。
能用原生掃描就用,不能就降級成手動輸入。
這裡還有一個平台細節。iOS 上要用 scanCodeV2,LIFF 的 view size 必須設成 Full。設成別的就是不能用,而且不會有任何訊息說明原因。
上面那幾節講的單一入口、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 就不是本機位址了,這道檢查不會擋。不過你跟著做的時候就是在自己電腦上,所以你跑起來也會被擋在這一步。
上面那個測試證明的是流程沒有少走一步。還有一個更直接的證據,在 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 只要你回答一件事:授權在哪裡結束。
前面說換掉外殼 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。