ข้ามไปยังเนื้อหา

SPA Frontend

SPA รันอยู่ทั้งหมดใน browser ของผู้ใช้ โค้ดฝั่ง client (รวมถึงค่าคงที่หรือ config ใด ๆ ที่ฝังไว้) จึงเปิดให้ทุกคนตรวจสอบได้เสมอ ดังนั้น SPA จึงไม่สามารถเก็บ client secret ไว้อย่างปลอดภัยเหมือนที่ backend ทำได้ Keycloak แก้ปัญหานี้โดยให้ SPA ลงทะเบียนเป็น public client (ไม่มี secret) แล้วใช้ PKCE (Proof Key for Code Exchange) แทน เพื่อป้องกันไม่ให้ authorization code ที่ถูกดักจับระหว่างทางถูกนำไปแลก token ได้

keycloak-js คือ adapter อย่างเป็นทางการฝั่ง browser สำหรับ Keycloak จัดการ Authorization Code + PKCE flow, การจัดเก็บ token, silent refresh และ logout โดยที่คุณไม่ต้องจัดการ redirect URL หรือการแลก token เอง

ติดตั้งผ่าน npm:

Terminal window
npm install keycloak-js

package มี TypeScript types มาในตัว ไม่จำเป็นต้องติดตั้ง @types แยกต่างหาก

ส่ง URL ของ realm, ชื่อ realm และ client ID เข้าไป ค่าสามตัวนี้คือขั้นต่ำที่จำเป็นสำหรับการระบุตัวตนแอปพลิเคชันให้กับ Keycloak:

import Keycloak from 'keycloak-js';

const keycloak = new Keycloak({
  url: 'http://localhost:8080',
  realm: 'my-app',
  clientId: 'my-app-frontend',
});

keycloak
  .init({ onLoad: 'login-required', pkceMethod: 'S256' })
  .then((authenticated) => {
    if (authenticated) {
      console.log('Logged in as', keycloak.tokenParsed?.preferred_username);
      startApp();
    }
  });

function startApp() {
  // Your app bootstrap goes here.
  // keycloak.token is available from this point on.
}

ประเด็นสำคัญ:

  • onLoad: 'login-required' จะ redirect ผู้ใช้ไปยัง Keycloak ทันทีหากยังไม่ได้ authenticate ใช้ 'check-sso' หากต้องการให้หน้าเว็บเข้าถึงได้โดยไม่ต้อง login และแสดงปุ่ม login แทน
  • pkceMethod: 'S256' เปิดใช้งาน PKCE (Proof Key for Code Exchange) ซึ่งจำเป็นสำหรับ public client (SPA) เพราะไม่สามารถเก็บ client secret ได้อย่างปลอดภัย PKCE แทนที่ secret ด้วย code challenge ที่สร้างขึ้นต่อ request

หลังจาก init resolve กลับมาด้วย authenticated: true แล้ว keycloak.token จะเก็บ JWT string ดิบไว้ แนบ token นี้เป็น Bearer token ใน API request ทุกครั้ง:

async function fetchProfile() {
  await keycloak.updateToken(30); // refresh if expiring in < 30 seconds

  const response = await fetch('https://api.example.com/profile', {
    headers: {
      Authorization: `Bearer ${keycloak.token}`,
    },
  });

  return response.json();
}

updateToken(minValidity) ตรวจสอบว่า token ปัจจุบันจะหมดอายุภายใน minValidity วินาทีหรือไม่ ถ้าใช่จะใช้ refresh token เพื่อรับ token ใหม่แบบ silent เรียกใช้ก่อนแนบ keycloak.token เสมอ เพื่อหลีกเลี่ยงการส่ง JWT ที่หมดอายุ

payload ที่ถอดรหัสแล้วอยู่ใน keycloak.tokenParsed claims ที่ใช้บ่อย:

keycloak.tokenParsed?.preferred_username // username
keycloak.tokenParsed?.email // email address
keycloak.tokenParsed?.realm_access?.roles // array of realm roles
keycloak.tokenParsed?.resource_access?.['my-app-frontend']?.roles // client roles
ตัวเลือกBenefitCost
เก็บ token ไว้ในหน่วยความจำ (in-memory)ปลอดภัยกว่า เพราะ JavaScript ฝั่งอื่นหรือ script ที่ถูกแทรกผ่าน XSS เข้าถึงได้ยากกว่า และ token จะหายไปเองเมื่อปิด tabtoken หายทุกครั้งที่ refresh หน้า ต้อง trigger silent SSO check ใหม่ผ่าน iframe หรือ redirect
เก็บ token ไว้ใน localStorageผู้ใช้ยังคง login อยู่แม้ refresh หน้าเว็บ ใช้งานง่ายเพราะไม่ต้องจัดการ silent refresh เองเปิดช่องให้ XSS attack ขโมย token ได้ทันที เพราะ script ใดก็ตามที่รันบนหน้าเว็บอ่าน localStorage ได้
ใช้ keycloak-js (official adapter)จัดการ PKCE flow, silent refresh และการจัดเก็บ token ให้อัตโนมัติ ลดโอกาสเขียนโค้ด security ผิดเองผูกกับ Keycloak โดยตรง หาก migrate ไป identity provider อื่นต้องเขียน integration ใหม่
  • เก็บ token ไว้ใน localStorage — เป็นความผิดพลาดที่พบบ่อยที่สุดของ SPA เพราะ localStorage เข้าถึงได้จาก JavaScript ทุกบรรทัดที่รันบนหน้า หาก third-party script หรือ dependency ที่ถูกแฮ็กมี XSS vulnerability ผู้โจมตีจะดึง token ออกไปได้ทันที ให้ปล่อยให้ keycloak-js เก็บ token ไว้ในหน่วยความจำตามค่าเริ่มต้นแทน
  • เชื่อ client-side “logged in” state โดยไม่ตรวจสอบที่ backend — การเช็คว่า keycloak.authenticated เป็น true บน frontend บอกได้แค่ว่า UI ควรแสดงอะไร ไม่ใช่หลักฐานว่า request นั้นถูกต้อง backend ต้อง verify JWT signature ทุกครั้งที่ endpoint
  • ไม่เรียก updateToken ก่อนยิง API ทุกครั้ง — การเก็บ token ไว้ในตัวแปรแล้วใช้ซ้ำโดยไม่เช็คว่าใกล้หมดอายุหรือยัง ทำให้ request ล้มเหลวกลางทางด้วย 401 ทั้งที่สามารถ refresh แบบ silent ล่วงหน้าได้

💡 ตัวอย่างจากของจริง

keycloak-js official pattern — Keycloak เองแนะนำให้เก็บ token ไว้ในหน่วยความจำ (JavaScript variable) เท่านั้น ไม่ใช่ localStorage หรือ sessionStorage เมื่อ refresh หน้า adapter จะทำ silent SSO check ผ่าน hidden iframe เพื่อขอ token ใหม่โดยผู้ใช้ไม่รู้ตัว

Auth0 SPA SDK — ใช้แนวทางเดียวกันคือ in-memory storage + refresh token rotation แทนการเก็บลง localStorage ซึ่งกลายเป็น best practice มาตรฐานของ OIDC client library สำหรับ browser ทั่วทั้งอุตสาหกรรม

ออปชัน pkceMethod: "S256" ทำหน้าที่อะไรเมื่อส่งให้ keycloak.init()?
เหตุใดจึงควรเรียก keycloak.updateToken(30) ก่อนแนบ token ไปกับ API request?
โดยค่าเริ่มต้น keycloak-js จัดเก็บ access token ไว้ที่ใด?
ค่า onLoad ใดที่ redirect ผู้ใช้ไปยัง Keycloak ทันทีหากยังไม่ได้ authenticate?