Skip to main content

การล็อกอิน LINE ผ่าน LIFF

ภาพรวม

กลไกนี้คือหัวใจของเว็บฝั่งลูกค้าทั้งหมด ทำหน้าที่พาผู้ใช้จากสถานะ "เพิ่งเปิดลิงก์ในแอป LINE" ไปสู่สถานะ "มี LINE ID token ที่ส่งให้ API ตรวจสอบได้"

ระบบ ไม่ได้ออก JWT ของตัวเอง ไม่มี session cookie และไม่ใช้ Authorization header API ฝั่งลูกค้าทุกเส้นทางยืนยันตัวตนด้วย HTTP header x-liff-token ซึ่งบรรจุ ID token ของ LINE และในบางเส้นทางจะแนบ x-liff-access-token เพิ่มเป็นตัวสำรอง

ตรรกะทั้งหมดรวมศูนย์อยู่ที่ hook useLiffAuth (src/hooks/use-liff-auth.ts) ซึ่งมีการป้องกัน edge case จำนวนมากที่เคยทำให้ผู้ใช้พบปัญหา "หน้าขาว" หรือ "ล็อกอินวนไม่จบ" นักพัฒนาที่ต้องแก้ไขส่วนนี้ควรเข้าใจ guard แต่ละชั้นก่อนปรับแก้

Business Flow

  1. หน้าเพจอ่านค่า hash จาก URL แล้วเรียก API เพื่อหา lineLiffId ของ LINE OA นั้น
  2. เก็บ URL ปัจจุบันไว้ใน useAppStore.redirectUri เพื่อใช้เป็นปลายทางหลังล็อกอินสำเร็จ
  3. เรียก useLiffAuth(liffId) ซึ่งจะทำงานตามลำดับดังนี้
    • เรียก liff.init() ด้วย LIFF ID ที่ได้มา
    • หากยังไม่ได้ล็อกอิน และผู้ใช้อยู่ในแอป LINE ระบบจะพาไปยัง LIFF URL ของแอป แต่หากอยู่นอกแอป LINE จะเรียก liff.login() พร้อม redirect URI ที่เก็บไว้
    • หากล็อกอินแล้ว ระบบจะล้าง query parameter ที่ LINE แนบมา (code, state, liffClientId, liffRedirectUri, liff.state) ออกจาก address bar ด้วย history.replaceState
    • อ่าน ID token ด้วย liff.getIDToken() โดยลองซ้ำได้สูงสุด 3 ครั้ง เว้นระยะครั้งละ 300 มิลลิวินาที เนื่องจาก token อาจยังไม่พร้อมทันทีหลัง init
    • ตรวจสอบวันหมดอายุจาก payload ของ JWT โดยมีหลักการสำคัญคือ หากอ่านค่าไม่สำเร็จจะไม่ถือว่าหมดอายุ เพื่อป้องกันกรณีที่การถอดรหัส base64url ล้มเหลว
    • หาก token หมดอายุจริง ระบบจะ logout แล้ว login ใหม่ เพียงครั้งเดียวเท่านั้น เพื่อป้องกันเครื่องที่ตั้งนาฬิกาคลาดเคลื่อนวนล็อกอินไม่รู้จบ
    • ดึงโปรไฟล์ด้วย liff.getProfile() แล้วเก็บลง useUserStore
  4. หน้าที่เรียกใช้ hook นี้จะเรียก getIdToken() เพื่อนำ token ไปใส่ใน header ตอนยิง API
  5. หากผู้ใช้กลับมาจาก LINE แล้วยังไม่อยู่ในสถานะล็อกอิน ระบบจะตั้งค่า loginFailed เป็นจริงพร้อมกับ isLiffReady เพื่อให้หน้าจอแสดงปุ่ม "เข้าสู่ระบบอีกครั้ง" แทนที่จะหมุนรอค้างไว้

การป้องกันปัญหา URI ยาวเกินขนาด

ทุกครั้งที่ล็อกอิน LINE จะห่อ redirect URI ทั้งก้อนไว้ใน liff.state ชั้นใหม่ หากเกิดการวนซ้ำ URL จะซ้อนกันจนมีขนาดเกินประมาณ 8KB ทำให้ nginx หรือ Next.js ตอบกลับด้วย HTTP 414

ตัวช่วย flattenRedirectUri() แก้ปัญหานี้โดยดึง query parameter จริงออกมาจาก liff.state เพียงหนึ่งชั้น แล้วตัด liff.state และ OAuth parameter ที่ไม่จำเป็นออกทั้งหมด

Guard สองชั้นใน sessionStorage

ระบบใช้คีย์สองตัวใน sessionStorage เพื่อจำกัดจำนวนครั้งของการล็อกอินอัตโนมัติต่อหนึ่งแท็บ

คีย์หน้าที่
liff-login-attemptedอนุญาตให้พยายามล็อกอินอัตโนมัติได้เพียงครั้งเดียวต่อแท็บ
liff-expired-reloginอนุญาตให้บังคับล็อกอินใหม่จากสาเหตุ token หมดอายุได้เพียงครั้งเดียว

คีย์ทั้งสองผูกกับ LIFF ID ของแอปนั้น ๆ

หน้าจอและองค์ประกอบหลัก

Hook หลัก

  • useLiffAuth(liffId) (src/hooks/use-liff-auth.ts) เป็นจุดศูนย์กลาง คืนค่าโปรไฟล์ผู้ใช้ ฟังก์ชันดึง ID token สถานะความพร้อมของ LIFF และสถานะการล็อกอินล้มเหลว ภายในมีตัวช่วยสำหรับอ่านวันหมดอายุของ token และล้าง OAuth parameter
  • useLiffInit(liffId) (src/hooks/use-liff-init.ts) ทำเพียงการ init LIFF โดย ไม่บังคับล็อกอิน ใช้ในหน้าที่ห้ามเด้งผู้ใช้ออก เช่น หน้าขอบคุณหลังส่งฟอร์ม
  • useFetchGetLineOaByHash(hash) (src/hooks/use-fetch-line-oa-by-hash.ts) แปลง hash ของ OA ให้เป็นข้อมูลการตั้งค่า LIFF
  • useAppLogout() เคลียร์ store ทั้งหมดพร้อมเรียก liff.logout() และ useRedirect() พาผู้ใช้กลับไปยัง redirect URI ที่เก็บไว้

ตัวช่วยระดับ library

  • flattenRedirectUri() (src/hooks/liff-redirect.ts) จัดการปัญหา URL ซ้อนชั้น พร้อมรายการ OAuth parameter ที่ต้องตัดทิ้ง
  • closeLiff() (src/lib/liff-close.ts) ปิดหน้าต่าง LIFF โดยมี window.close() เป็นทางสำรอง
  • liffSameOriginPath() (src/lib/liff-same-origin.ts) แปลงลิงก์ LIFF ที่ชี้กลับมายังแอปตัวเองให้เป็น path แบบ same-origin เพื่อไม่ให้เกิดหน้าต่าง LIFF ซ้อนกันหลายชั้น

Wrapper สำหรับฟีเจอร์ที่ต้องล็อกอิน

  • useBulletinAuth (src/app/[hash]/bulletin/hooks/) รวม chain ทั้งหมดตั้งแต่ hash ไปจนถึงการประกอบ header ไว้ในที่เดียว และถูกใช้ซ้ำโดยฟีเจอร์บอร์ดประกาศ บัตรสะสมแต้ม และเครื่องมือพนักงาน

Endpoint ที่เกี่ยวข้อง

  • GET /line-oa/get-by-hash/:hash คืนค่า lineLiffId, formLiffId, botBasicId รวมถึงชื่อและรูปภาพของ OA

การทำงานของ flattenRedirectUri() มี unit test กำกับอยู่ที่ src/hooks/__tests__/liff-redirect.test.ts

จุดเชื่อมต่อกับฟีเจอร์อื่น

  • @line/liff v2 เป็น SDK หลัก ใช้ความสามารถหลากหลายตั้งแต่ init, isLoggedIn, login, logout, getIDToken, getAccessToken, getProfile, isInClient, isApiAvailable, scanCodeV2 ไปจนถึง closeWindow
  • LINE OA service (src/service/line-oa.service.ts) เป็นแหล่งข้อมูลการตั้งค่า LIFF ของแต่ละ OA
  • Zustand store ใช้ useAppStore เก็บ redirect URI และ LIFF ID ส่วน useUserStore เก็บโปรไฟล์ผู้ใช้ (ดู โครงแอปและ Provider หลัก)
  • ทุกฟีเจอร์ที่ต้องรู้ตัวตนผู้ใช้ผูกกับ hook นี้ทั้งหมด ได้แก่ กรอกฟอร์ม, บอร์ดประกาศ, บัตรสะสมแต้ม, แคมเปญเพิ่มเพื่อน, การจองนัดหมาย และ เมนูสาธารณะ
  • ข้อควรทราบ บางหน้า เช่น แคมเปญเพิ่มเพื่อนและการจองนัดหมาย เรียก liff.init() เองโดยตรงแทนการใช้ hook เนื่องจากต้องควบคุมลำดับการดึง token และโปรไฟล์เป็นกรณีพิเศษ

รายละเอียดฝั่ง Backend (Client API)

ฝั่ง backend ก็ไม่มี JWT ของตัวเอง ไม่มี session และไม่อ่าน Authorization header เช่นกัน — ทุกครั้งที่หน้าเว็บส่ง x-liff-token มา client-api จะเป็นผู้ไปตรวจ token กับ LINE Platform เองแบบสดๆ ไม่ได้เชื่อ token ตามหน้าตาหรือ decode อ่านเอง

หัวใจสำคัญที่สุด: channel binding

นี่คือแนวคิดที่อธิบายพฤติกรรม auth เกือบทั้งหมดของ backend

  • token ที่ valid พิสูจน์ได้แค่ว่า "มีบัญชี LINE บัญชีหนึ่งอยู่จริง" ไม่ได้พิสูจน์ว่าบัญชีนั้นเป็นของ tenant นี้ — เพราะใครก็สร้าง LINE Login channel ของตัวเองแล้วออก token ที่ valid ได้
  • ดังนั้นทุก flow ต้อง ผูก token เข้ากับ LINE Login channel ของ OA ที่ resolve ได้จาก hash หรือ token ในเส้นทางก่อน จึงจะยอมรับ
  • ผลที่มองเห็นจากฝั่งเว็บ: token ที่ใช้ได้กับ OA หนึ่ง จะใช้กับ OA อื่นไม่ได้ แม้ผู้ใช้จะเป็นคนเดียวกันและ token ยังไม่หมดอายุ

เส้นทางการ verify ที่ backend ใช้ (มี 3 แบบ ไม่เหมือนกัน)

แบบที่ 1 — verify ID token + auto-provision (ใช้กับฟอร์ม, เมนูสาธารณะ, เนื้อหาสาธารณะ, tracking)

  1. โหลด OA ที่สถานะ active และยังไม่ถูกลบ — หาไม่เจอ → 401 ข้อความ Invalid channel
  2. OA ที่ยังไม่ตั้งค่า LINE Login channel → 401 ข้อความ LINE Login not configured for this channel
  3. verify ID token กับ primary channel ก่อน ถ้าไม่ผ่านและ OA มี LIFF สำหรับฟอร์มที่อยู่ต่าง channel จึงลองอีกครั้งด้วย channel ของฟอร์ม (นี่คือเหตุผลที่ token จากหน้าฟอร์มใช้งานได้ทั้งที่ LIFF ID ไม่ใช่ตัวหลัก)
  4. หาผู้ใช้จากคู่ (LINE user id, OA) — ถ้ายังไม่มีจะสร้างให้อัตโนมัติทันทีเป็น guest โดยใช้ชื่อจาก claim ของ token หรือค่าเริ่มต้น Guest User, ภาษาเริ่มต้นเป็นไทย, สถานะเป็นเพื่อนแล้ว และประเภทผู้ใช้เป็น guest
  5. คืน user id พร้อมข้อมูลผู้ใช้ให้ปลายทางใช้ต่อ

ข้อสำคัญ: การเปิดหน้าเว็บครั้งแรกของผู้ใช้ใหม่จึงมีผลข้างเคียงคือสร้างแถวผู้ใช้ในระบบเลย ไม่ต้องรอให้กดส่งฟอร์ม

แบบที่ 2 — ladder แบบผูก channel (ใช้กับบอร์ดประกาศและบัตรสะสมแต้ม)

  1. ไม่ส่ง token มาเลยทั้งสองตัว → 401 No authentication token provided
  2. OA ไม่มี primary channel → 401 ทันที (fail closed — ไม่มีอะไรให้ผูก จึงไม่ยอมรับอะไรเลย)
  3. ถ้ามี x-liff-token จะลอง verify เป็น ID token ไล่ทีละ channel ที่ผูกไว้ (primary ก่อน แล้วค่อย channel ของฟอร์ม)
  4. ถ้ายังไม่ผ่าน จึงลองตีความเป็น access token — โดยลองทั้ง x-liff-access-token และ x-liff-token (เพราะ client บางตัวส่ง access token มาในช่อง x-liff-token) ขั้นนี้จะ introspect token ก่อน แล้วบังคับว่า channel ของ token ต้องอยู่ในรายการที่ผูกไว้ ถ้าไม่ตรงปฏิเสธทันที ผ่านแล้วจึงไปดึงโปรไฟล์
  5. ทุกความล้มเหลวคืน 401 ด้วยข้อความเดียวกันคือ Invalid or expired LIFF token
  • ข้อสังเกตด้าน security: ข้อความ error ถูกทำให้เหมือนกันทั้งหมดโดยเจตนา — แยกไม่ออกว่า "token ผิด" หรือ "token ถูกแต่ผิด channel" ดังนั้นฝั่งเว็บ ไม่ควรพยายามเดาสาเหตุจากข้อความ ให้ปฏิบัติเหมือนกันคือขอให้ล็อกอินใหม่
  • channel id ที่ว่างเปล่า ไม่ถือว่า "ไม่มีข้อจำกัด" — ไม่เคยผ่านการตรวจ

แบบที่ 3 — verify access token ตรงๆ โดยไม่ผูก channel (การจองนัดหมาย และแคมเปญเพิ่มเพื่อน)

สองฟีเจอร์นี้ verify access token กับ LINE โดยตรงเพื่อ parity กับระบบเดิม — การจองนัดหมายไม่มี channel binding ซึ่งเป็นจุดที่อ่อนกว่าเส้นทางอื่นและควรรู้ไว้ ส่วนแคมเปญเพิ่มเพื่อนถ้าส่ง x-liff-token มาจะ verify กับ primary channel ของ OA ตรงๆ (ถ้า OA ไม่มี channel จะได้ 401)

การตรวจสิทธิ์เข้าถึงเนื้อหาที่จำกัดกลุ่มผู้ชม

เนื้อหาที่กำหนด audience ไว้จะใช้เส้นทาง verify ที่ต่างจากปกติ 2 จุดโดยเจตนา คือ ไม่มี fallback ไป channel ของฟอร์ม (ใช้ primary เท่านั้น) และ error เป็น 403 ไม่ใช่ 401 — ฝั่งเว็บควรแยกสองสถานะนี้ให้ผู้ใช้เห็นต่างกัน (401 = ให้ล็อกอินใหม่, 403 = ไม่มีสิทธิ์ ล็อกอินใหม่ก็ไม่ช่วย)

กฎการเทียบ audience:

  • เนื้อหาที่ไม่ได้กำหนด audience → ทุกคนเข้าได้ (public)
  • เนื้อหากำหนด audience แต่ผู้ใช้ไม่ได้อยู่ในกลุ่มใดเลย → 403 You do not have access to this content
  • ทั้งสองฝ่ายมี audience → ผ่านถ้ามี ซ้อนกันอย่างน้อย 1 กลุ่ม (เป็น OR ไม่ใช่ AND)

พฤติกรรม "degrade อย่างนุ่มนวล" ที่ทำให้เว็บไม่พังเมื่อ verify ไม่ผ่าน

บาง endpoint ตั้งใจกลืนความล้มเหลวของการ verify แทนที่จะตอบ error — จุดนี้อธิบายอาการที่หน้าเว็บ "แสดงผลได้แต่เห็นข้อมูลไม่ครบ" โดยไม่มี error ขึ้นเลย

  • เมนูสาธารณะ และ รายการลิงก์เนื้อหา: verify ไม่ผ่าน → คืนเฉพาะรายการที่เป็น public (ไม่ใช่ 401)
  • tracking redirect: บันทึกคลิกแบบไม่ระบุตัวตนแล้วพา redirect ต่อตามปกติ
  • การเช็คว่าเคยส่งฟอร์มแล้วหรือยัง: verify ไม่ผ่าน → คืนว่า "ยังไม่ส่ง" (ผู้ใช้จึงเห็นฟอร์มเปล่าแทนหน้าที่บอกว่าส่งแล้ว)

Edge case ที่ควรรู้

  • backend เรียก LINE Platform ทุกครั้งที่ verify และมี timeout กำกับ — ถ้า LINE ตอบช้า ผู้ใช้จะเห็น request ค้างนานกว่าปกติ ไม่ใช่ปัญหาที่ตัวเว็บ
  • OA ที่ถูกปิดหรือลบไปแล้วจะให้ 401 Invalid channel แม้ token ยังใช้งานได้ปกติ — อาการจะเหมือน token หมดอายุแต่การล็อกอินใหม่ไม่ช่วย
  • ตรรกะ ladder ของบอร์ดประกาศและบัตรสะสมแต้มถูกเขียนแยกกันโดยเจตนา (ไม่ import ข้ามโดเมน) ดังนั้นถ้าพฤติกรรม auth ของสองฟีเจอร์นี้ต่างกันเล็กน้อย ถือเป็นเรื่องที่คาดไว้แล้ว
  • endpoint ที่หน้าเว็บใช้แปลง hash เป็นค่าตั้งค่า LIFF จะคืนทั้ง LIFF ID หลักและ LIFF ID ของฟอร์ม เพราะทั้งสองค่านั้นเป็นตัวกำหนดว่า backend จะยอมรับ token จาก channel ใดได้