Backend API
สิ่งที่ resource server ต้องทำ
หัวข้อที่มีชื่อว่า “สิ่งที่ resource server ต้องทำ”เมื่อ client ส่ง request พร้อม header Authorization: Bearer <token> backend ของคุณต้อง:
- ดึง JWKS ของ realm — ชุด public key ที่ Keycloak ใช้เซ็น JWT URL คือ
<keycloak-url>/realms/<realm>/protocol/openid-connect/certs - ตรวจสอบ signature — ยืนยันว่า JWT ถูกเซ็นโดย Keycloak ไม่ใช่ปลอมโดยผู้โจมตี
- ตรวจสอบ issuer (
iss) — claimissใน token ต้องเท่ากับ<keycloak-url>/realms/<realm>token จาก realm อื่นหรือ server อื่นต้องถูกปฏิเสธ - ตรวจสอบ audience (
aud) — claimaudควรมี client ID ของคุณ เพื่อยืนยันว่า token ถูก issue สำหรับ API ของคุณ - ตรวจสอบ expiry (
exp) — ปฏิเสธ token ที่หมดอายุแล้ว
หากตรวจสอบล้มเหลวในข้อใดข้อหนึ่ง token นั้นไม่ถูกต้องและต้องปฏิเสธ request ด้วย 401 Unauthorized
ตัวอย่าง Node/Express ด้วย jose
หัวข้อที่มีชื่อว่า “ตัวอย่าง Node/Express ด้วย jose”ไลบรารี jose เป็น JWT library ที่ทันสมัย ไม่มี dependency เสริม รองรับการดึง JWKS และการตรวจสอบมาตรฐานทั้งหมด ติดตั้ง:
npm install joseimport { createRemoteJWKSet, jwtVerify } from 'jose';
import express from 'express';
const KEYCLOAK_URL = 'http://localhost:8080';
const REALM = 'my-app';
const CLIENT_ID = 'my-app-backend';
// Cache the JWKS remote keyset — jose handles key rotation automatically.
const JWKS = createRemoteJWKSet(
new URL(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/certs`)
);
async function requireAuth(req, res, next) {
const authHeader = req.headers['authorization'] ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return res.status(401).json({ error: 'Missing bearer token' });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `${KEYCLOAK_URL}/realms/${REALM}`,
audience: CLIENT_ID,
});
req.user = payload; // attach claims to the request
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
const app = express();
app.get('/profile', requireAuth, (req, res) => {
res.json({
username: req.user.preferred_username,
email: req.user.email,
roles: req.user.realm_access?.roles ?? [],
});
});
app.listen(3001, () => console.log('API listening on :3001'));โค้ดทำงานอย่างไร
หัวข้อที่มีชื่อว่า “โค้ดทำงานอย่างไร”createRemoteJWKSetสร้าง key set ที่ cache ไว้จาก JWKS URL ของ Keycloak เมื่อ Keycloak หมุนเวียน keyjoseจะดึง key ใหม่โดยอัตโนมัติjwtVerifyตรวจสอบ signature,iss,audและปฏิเสธ token ที่หมดอายุ หากตรวจสอบล้มเหลวจะ throw error — blockcatchจะส่ง401กลับ- หลังการตรวจสอบสำเร็จ
payloadจะมี claims ทั้งหมดที่ถอดรหัสแล้ว (sub, preferred_username, realm_access เป็นต้น) และสามารถนำไปใช้ใน authorisation logic ได้
การตรวจสอบ roles จาก token
หัวข้อที่มีชื่อว่า “การตรวจสอบ roles จาก token”Keycloak เข้ารหัส realm roles ใน realm_access.roles และ client-specific roles ใน resource_access.<clientId>.roles helper ตรวจสอบ role:
function hasRole(user: Record<string, unknown>, role: string): boolean { const roles = (user.realm_access as { roles?: string[] })?.roles ?? []; return roles.includes(role);}ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| ตรวจสอบ JWT ทุก request (stateless validation) | ไม่ต้อง query session store ทุกครั้ง scale ได้ง่ายเพราะ backend ไม่ต้องเก็บ state | ต้อง verify signature ด้วย cryptography ทุก request ซึ่งมี CPU cost เล็กน้อยต่อ request |
| Cache JWKS ไว้ในหน่วยความจำ (เช่น createRemoteJWKSet ของ jose) | ลดจำนวนครั้งที่ต้องเรียก JWKS endpoint ของ Keycloak ทำให้ latency ต่ำลง | ถ้า Keycloak หมุนเวียน key กะทันหันโดยไม่รอ cache หมดอายุ อาจมี window สั้น ๆ ที่ verify ล้มเหลวจนกว่า library จะดึง key ใหม่ |
| ใช้ library ตรวจสอบ JWT มาตรฐาน (เช่น jose) | เป็น library ที่ community ตรวจสอบแล้ว รองรับ JWKS, key rotation และมาตรฐาน OIDC ครบ | ต้องเขียน middleware เชื่อมกับ framework เอง ไม่ได้ผูกกับ Keycloak โดยตรงเหมือน adapter เฉพาะทาง |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- backend ไม่ตรวจสอบ claim aud/iss ทุก request — ตรวจแค่ signature อย่างเดียวไม่พอ เพราะ token ที่เซ็นถูกต้องจาก realm อื่นหรือ client อื่นก็ผ่านการตรวจ signature ได้เช่นกัน ต้องเช็ค iss ให้ตรงกับ realm ของคุณ และ aud ให้ตรงกับ client ID ของ backend ทุกครั้ง
- เชื่อ token ที่แนบมาโดยไม่ verify signature กับ JWKS — การ decode JWT แล้วอ่าน claims ตรง ๆ โดยไม่ verify signature เท่ากับเปิดให้ผู้โจมตีปลอม payload อะไรก็ได้ ต้องใช้ library อย่าง jose หรือ jjwt เพื่อ verify กับ public key จาก JWKS เสมอ
- ไม่จัดการ key rotation — hardcode public key ไว้ในโค้ดแทนที่จะดึงจาก JWKS endpoint แบบ dynamic ทำให้เมื่อ Keycloak หมุนเวียน key แล้ว backend verify token ใหม่ไม่ผ่านจนกว่าจะ deploy โค้ดใหม่
💡 ตัวอย่างจากของจริง
Spring Security oauth2ResourceServer — เป็นตัวอย่างมาตรฐานของ backend-side JWT validation middleware กำหนดแค่ issuer-uri ก็ดึง JWKS, ตรวจ signature, iss, aud และ exp ให้อัตโนมัติโดยไม่ต้องเขียน validation logic เอง
jose + createRemoteJWKSet — ใช้ในตัวอย่าง Node/Express ของบทเรียนนี้ เป็น pattern เดียวกับที่ทีม backend จำนวนมากใช้จริงใน production เพราะ cache JWKS อัตโนมัติและรองรับ key rotation โดยไม่ต้อง restart service