- สร้าง Express API ที่ควบคุมสิทธิ์ด้วย Keycloak และ Access Token
- 1. OAuth 2.0, OIDC และ JWT ต่างกันอย่างไร
- 2. เลือก Flow ให้เหมาะกับงาน
- 3. สถาปัตยกรรมและลำดับการทำงาน
- 4. เตรียมเครื่องมือ
- 5. เริ่ม Keycloak ด้วย Docker Compose
- 6. ตั้งค่า Realm, Client และ Scopes
- 7. เขียน Express API
- 8. ขอ Token แบบ Client Credentials
- 9. ตารางทดสอบ API
- 10. ต่อด้วยผู้ใช้ล็อกอิน: Authorization Code + PKCE
- 11. ข้อควรออกแบบก่อนใช้จริง
- 12. แก้ปัญหาที่พบบ่อย
- 13. แบบฝึกหัดต่อยอด
- 14. หยุด Workshop
- 15. สรุป
- แหล่งอ้างอิง
- สถานะการตรวจสอบตัวอย่าง
#สร้าง Express API ที่ควบคุมสิทธิ์ด้วย Keycloak และ Access Token
OAuth 2.0 เป็นกรอบการมอบสิทธิ์ให้แอปพลิเคชันเข้าถึงทรัพยากร เช่น API โดยใช้ Access Token แทนการส่งรหัสผ่านของผู้ใช้ให้ทุกบริการ สำหรับ Workshop นี้ Express ทำหน้าที่ Resource Server และ Keycloak ทำหน้าที่ Authorization Server ผู้เรียกต้องขอ Token จาก Keycloak แล้วส่งให้ Express ตรวจสอบก่อนเข้าถึงข้อมูล
บทความนี้ใช้ Node.js 24, Express 5, jose 6 และ Keycloak 26.8.0 ซึ่งเป็น tag ที่ปรากฏในคู่มือ Docker ของ Keycloak ณ วันที่ตรวจสอบ 13 ตุลาคม 2026 ไม่ได้หมายความว่าเป็นเวอร์ชันที่ติดตั้งหรือทดสอบ Keycloak ในสภาพแวดล้อมของผู้เขียนแล้ว [1][2][3]
เวลาเรียนโดยประมาณ: 60–90 นาที
พื้นฐาน: JavaScript, HTTP, REST API และ Docker Compose
ผลลัพธ์: API สำหรับอ่านและสร้างรายการสั่งซื้อ โดยแยกสิทธิ์ orders:read และ orders:write
#1. OAuth 2.0, OIDC และ JWT ต่างกันอย่างไร
| คำ | หน้าที่ | ตัวอย่างใน Workshop |
|---|---|---|
| OAuth 2.0 | มอบสิทธิ์เข้าถึงทรัพยากร | อนุญาตให้อ่านรายการสั่งซื้อ |
| OpenID Connect หรือ OIDC | เพิ่มการยืนยันตัวตนบน OAuth 2.0 | ล็อกอินและรับ ID Token |
| Access Token | หลักฐานสำหรับเรียก API | ส่งใน Authorization: Bearer ... |
| ID Token | ให้ Client ตรวจสอบข้อมูลการยืนยันตัวตน | ใช้ในขั้นตอนล็อกอิน ไม่ส่งแทน Access Token |
| JWT | รูปแบบ Token ที่มี Claims และลายเซ็น | Keycloak ออก JWT Access Token |
| Scope | ขอบเขตสิทธิ์ที่ได้รับ | orders:read |
| JWKS | ชุด Public Keys สำหรับตรวจลายเซ็น | Express อ่านจาก Keycloak |
OAuth 2.0 ไม่ได้กำหนดว่า Access Token ทุกตัวต้องเป็น JWT บางระบบใช้ Opaque Token และต้องตรวจผ่าน Introspection Endpoint ตัวอย่างนี้เลือก JWT ตามการตั้งค่า Keycloak ส่วน OIDC กำหนด ID Token สำหรับ Client ที่ทำการล็อกอิน [4][5]
#2. เลือก Flow ให้เหมาะกับงาน
| สถานการณ์ | Flow | ข้อสังเกต |
|---|---|---|
| Backend เรียก Backend, งานอัตโนมัติ | Client Credentials | Token แทนตัว Client หรือ Service Account |
| เว็บหรือแอปที่มีผู้ใช้ล็อกอิน | Authorization Code + PKCE | Token อยู่ในบริบทของผู้ใช้ |
| Browser SPA หรือ Mobile App | Authorization Code + PKCE | Public Client เก็บ Client Secret ให้เป็นความลับไม่ได้ |
| เว็บที่มี Backend จัดการ Session | Authorization Code + PKCE | Backend สามารถเป็น Confidential Client ได้ |
Workshop หลักใช้ Client Credentials เพื่อให้เริ่มทดสอบ API ได้ทันที และมีส่วนต่อยอดสำหรับผู้ใช้ด้วย Authorization Code + PKCE แนวทางความปลอดภัย RFC 9700 ระบุว่าไม่ควรใช้การออก Access Token ผ่าน Implicit Flow และห้ามใช้ Resource Owner Password Credentials Grant [6]
#3. สถาปัตยกรรมและลำดับการทำงาน
sequenceDiagram
participant C as Backend Client
participant K as Keycloak
participant E as Express API
C->>K: Client Credentials + Scope
K-->>C: Access Token
C->>E: GET /api/orders + Bearer Token
E->>K: อ่าน JWKS เมื่อจำเป็น
K-->>E: Public Keys
Note over E: ตรวจลายเซ็น, iss, aud, exp, Scope
E-->>C: 200 หรือ 401 หรือ 403
jose สามารถตรวจ JWT ด้วย Public Keys จาก Remote JWKS ได้ จึงไม่ต้องแชร์ Private Key ของ Keycloak กับ Express และไม่จำเป็นต้องเรียก Keycloak ทุกครั้งที่ตรวจ Token เพราะตัว Resolver มี Cache [3][7]
#4. เตรียมเครื่องมือ
ติดตั้ง Node.js 24, Docker พร้อม Compose, curl และ jq คำสั่ง Shell ในบทความใช้ Bash เช่น macOS, Linux หรือ WSL
node --version
docker compose version
jq --version
mkdir express-oauth2-workshop
cd express-oauth2-workshop
npm init -y
npm install express@5 jose@6 helmet@8
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env server.js"
เก็บ package-lock.json ไว้เมื่อทำ Workshop เพื่อใช้ npm ci ติดตั้งรุ่นเดิมซ้ำใน CI
ไฟล์ที่ต้องสร้างมี compose.yaml, .env, .gitignore, app.js และ server.js
#5. เริ่ม Keycloak ด้วย Docker Compose
สร้างไฟล์ compose.yaml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.8.0
command: ["start-dev"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set password in .env}
ports:
- "127.0.0.1:8080:8080"
volumes:
- keycloak_data:/opt/keycloak/data
volumes:
keycloak_data:
สร้างไฟล์ .env และเปลี่ยนรหัสผ่านตัวอย่างก่อนเริ่ม
KEYCLOAK_ADMIN_PASSWORD=change-this-local-admin-password
PORT=3000
OAUTH_ISSUER=http://localhost:8080/realms/express-demo
OAUTH_AUDIENCE=express-api
สร้าง .gitignore
node_modules/
.env
docker compose up -d
docker compose logs -f keycloak
เมื่อ Keycloak เริ่มสำเร็จ กด Ctrl+C เพื่อออกจากการติดตาม Log แล้วเปิด Admin Console ล็อกอินด้วย admin และรหัสผ่านที่ตั้งไว้
start-dev และ HTTP ในบทความนี้ใช้สำหรับเครื่องเรียนเท่านั้น ระบบจริงต้องใช้ HTTPS, ฐานข้อมูลที่เหมาะกับ Production และการตั้งค่า Hostname/Proxy ที่ถูกต้อง [1]
#6. ตั้งค่า Realm, Client และ Scopes
ชื่อเมนูอาจต่างกันเล็กน้อยระหว่างรุ่น ให้ยึดค่าที่ต้องตั้งเป็นหลัก
#6.1 สร้าง Realm
เลือก Create realm ตั้งชื่อ express-demo และใช้ Realm นี้สำหรับทุกขั้นตอนต่อไป ไม่สร้างแอปพลิเคชันใน Realm master
#6.2 สร้าง Client สำหรับงาน Backend
ไปที่ Clients → Create client และตั้งค่า
| รายการ | ค่า |
|---|---|
| Client type | OpenID Connect |
| Client ID | workshop-machine |
| Client authentication | On |
| Standard flow | Off |
| Direct access grants | Off |
| Implicit flow | Off |
| Service accounts roles | On |
หลังบันทึก เปิดแท็บ Credentials แล้วเก็บ Client Secret ไว้ในฝั่ง Backend เท่านั้น Flow นี้ไม่ได้ให้ผู้ใช้ส่ง Username/Password มาให้ Express
#6.3 สร้าง Scope สำหรับอ่านและเขียน
ไปที่ Client scopes → Create client scope สร้างสองรายการ
| Name | Protocol | Include in token scope | Type เมื่อสร้าง |
|---|---|---|---|
orders:read |
OpenID Connect | On | None |
orders:write |
OpenID Connect | On | None |
หากหน้าจอมี Display on consent screen สามารถเปิดและกำหนดคำอธิบายสำหรับผู้ใช้ได้ แต่ Client Credentials ไม่มีผู้ใช้มากด Consent
จากนั้นไปที่ Clients → workshop-machine → Client scopes → Add client scope เพิ่มทั้งสอง Scope เป็น Optional การเรียก Token ต้องระบุชื่อ Scope ที่ต้องการ [8]
#6.4 เพิ่ม Audience ให้ Access Token
สร้าง Client Scope ชื่อ express-api-audience โดยใช้ Protocol OpenID Connect, Type None และปิด Include in token scope เพราะ Scope นี้ทำหน้าที่เติม Audience
ใน Scope นี้ไปที่ Mappers → Configure a new mapper → Audience แล้วตั้งค่า
| รายการ | ค่า |
|---|---|
| Name | express-api-aud |
| Included Client Audience | เว้นว่าง |
| Included Custom Audience | express-api |
| Add to access token | On |
| Add to ID token | Off |
นำ Scope express-api-audience ไปผูกกับ workshop-machine แบบ Default เพื่อให้ Access Token มี aud สำหรับ Express เสมอ ชื่อ Audience เป็นค่าที่เราออกแบบสำหรับ API ไม่จำเป็นต้องเท่ากับ Client ID ของผู้ขอ Token [9]
ขอบเขตการสาธิต: Optional Scope ที่ผูกกับ Client นี้เป็นสิทธิ์ที่ผู้ดูแลอนุญาตให้ Client ขอได้ ไม่ได้บังคับว่าเฉพาะผู้ใช้บางคนจึงขอ orders:write ได้ ระบบจริงต้องออกแบบ Role/Policy และสิทธิ์ระดับข้อมูลเพิ่ม เช่น Client สำหรับอ่านอย่างเดียวไม่ควรถูกผูกกับ Scope เขียน
#7. เขียน Express API
สร้าง app.js
import express from 'express';
import helmet from 'helmet';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const invalidTokenCodes = new Set([
'ERR_JWT_EXPIRED',
'ERR_JWT_CLAIM_VALIDATION_FAILED',
'ERR_JWT_INVALID',
'ERR_JWS_INVALID',
'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
'ERR_JOSE_ALG_NOT_ALLOWED',
'ERR_JWKS_NO_MATCHING_KEY',
]);
export function createApp({ issuer, audience, jwksUrl }) {
if (!issuer || !audience) {
throw new Error('OAUTH_ISSUER and OAUTH_AUDIENCE are required');
}
const JWKS = createRemoteJWKSet(
new URL(jwksUrl ?? `${issuer}/protocol/openid-connect/certs`),
{ timeoutDuration: 5000 }
);
const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '32kb' }));
const orders = [
{ id: 1, product: 'Software Engineering Book', quantity: 1 },
];
function rejectToken(res) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
return res.status(401).json({ error: 'invalid_token' });
}
async function authenticate(req, res, next) {
const header = req.get('authorization');
if (!header) {
res.set('WWW-Authenticate', 'Bearer');
return res.status(401).json({ error: 'authentication_required' });
}
const match = /^Bearer ([A-Za-z0-9._~-]+)$/i.exec(header);
if (!match) return rejectToken(res);
try {
const { payload } = await jwtVerify(match[1], JWKS, {
issuer,
audience,
algorithms: ['RS256'],
requiredClaims: ['iss', 'aud', 'sub', 'iat', 'exp'],
clockTolerance: 5,
});
// Keycloak-specific access-token marker, not a universal OAuth rule.
if (payload.typ !== 'Bearer') return rejectToken(res);
req.auth = payload;
return next();
} catch (error) {
if (invalidTokenCodes.has(error.code)) return rejectToken(res);
// Unknown verification / JWKS failures fail closed.
console.error('Token verification unavailable:', error.code ?? error.name);
return res.status(503).json({ error: 'token_verification_unavailable' });
}
}
function requireScope(scope) {
return (req, res, next) => {
const scopes = new Set(
typeof req.auth.scope === 'string'
? req.auth.scope.split(/\s+/).filter(Boolean)
: []
);
if (!scopes.has(scope)) {
res.set(
'WWW-Authenticate',
`Bearer error="insufficient_scope", scope="${scope}"`
);
return res.status(403).json({ error: 'insufficient_scope' });
}
return next();
};
}
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/api/orders', authenticate, requireScope('orders:read'),
(req, res) => res.json({ data: orders })
);
app.post('/api/orders', authenticate, requireScope('orders:write'),
(req, res) => {
const { product, quantity } = req.body ?? {};
if (
typeof product !== 'string' || !product.trim() ||
product.trim().length > 120 ||
!Number.isInteger(quantity) || quantity < 1 || quantity > 1000
) {
return res.status(400).json({ error: 'invalid_order' });
}
const order = {
id: orders.length + 1, product: product.trim(), quantity,
};
orders.push(order);
return res.status(201).json({ data: order });
}
);
app.use((err, req, res, next) => {
const status = err.type === 'entity.too.large' ? 413
: err.type === 'entity.parse.failed' ? 400 : 500;
res.status(status).json({ error: status === 500
? 'internal_error' : 'invalid_request' });
});
return app;
}
สร้าง server.js
import { createApp } from './app.js';
const app = createApp({
issuer: process.env.OAUTH_ISSUER,
audience: process.env.OAUTH_AUDIENCE,
});
app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1', () => {
console.log('Express API ready on http://localhost:3000');
});
npm start
โค้ดผูก API ไว้กับ Loopback สำหรับทดลองบนเครื่องเดียวกับ Keycloak หากนำไปใส่ Container ต้องปรับ Bind Address และ URL ให้ตรงกับการออกแบบเครือข่าย
#ทำไมต้องตรวจหลายค่า
| สิ่งที่ตรวจ | เหตุผล |
|---|---|
| Signature | ตรวจว่า Token ลงนามด้วย Key ที่เชื่อถือ |
iss |
ต้องมาจาก Realm ที่กำหนด |
aud |
ต้องออกสำหรับ API นี้ |
exp และ nbf ถ้ามี |
ตรวจช่วงเวลาที่ Token ใช้ได้ |
alg |
จำกัดเป็น RS256 ตามการตั้งค่า Workshop |
typ ใน Payload |
ยอมรับ Keycloak Access Token ชนิด Bearer |
scope |
ตรวจสิทธิ์ของ Endpoint หลังตรวจ Token |
การ Decode JWT อย่างเดียวไม่ยืนยันลายเซ็นและห้ามใช้ตัดสินสิทธิ์ ส่วนค่าประเภท typ ใน Payload เป็นเงื่อนไขเฉพาะ Keycloak ในบทความนี้ ผู้ให้บริการรายอื่นอาจใช้รูปแบบต่างกัน ต้องปรับตามเอกสารของผู้ให้บริการ [3]
401 ใช้เมื่อไม่มีหลักฐานการยืนยันตัวตนที่ใช้ได้ ส่วน Token ที่ถูกต้องแต่ขาด Scope ใช้ 403 พร้อม insufficient_scope ตาม Bearer Token specification [5]
#8. ขอ Token แบบ Client Credentials
เปิด Terminal อีกหน้าต่าง แล้วรับ Client Secret แบบไม่แสดงบนหน้าจอ
read -r -s -p 'Client secret: ' CLIENT_SECRET
printf '\n'
TOKEN_ENDPOINT='http://localhost:8080/realms/express-demo/protocol/openid-connect/token'
TOKEN_READ=$(curl --fail-with-body -sS "$TOKEN_ENDPOINT" \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=workshop-machine' \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode 'scope=orders:read' | jq -er '.access_token')
หากคำสั่งล้มเหลว ให้ตรวจ JSON Error ก่อนทำต่อ ห้ามใช้ค่าว่างหรือ null แทน Token ตัวอย่างนี้เหมาะกับเครื่องเรียน; Production ควรใช้ Secret Manager และ Client Authentication ที่เหมาะสม เพราะค่าใน Process Arguments อาจถูกตรวจดูได้บนเครื่องเดียวกัน
ทดสอบอ่านข้อมูล
curl -i http://localhost:3000/api/orders \
-H "Authorization: Bearer $TOKEN_READ"
ผลที่คาดหวัง: 200 OK และรายการสั่งซื้อ
ลองเขียนด้วย Token ที่อ่านได้อย่างเดียว
curl -i -X POST http://localhost:3000/api/orders \
-H "Authorization: Bearer $TOKEN_READ" \
-H 'Content-Type: application/json' \
-d '{"product":"OAuth Workshop","quantity":2}'
ผลที่คาดหวัง: 403 Forbidden และ insufficient_scope
ขอ Token ที่มีสิทธิ์เขียน
TOKEN_WRITE=$(curl --fail-with-body -sS "$TOKEN_ENDPOINT" \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=workshop-machine' \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode 'scope=orders:read orders:write' | jq -er '.access_token')
curl -i -X POST http://localhost:3000/api/orders \
-H "Authorization: Bearer $TOKEN_WRITE" \
-H 'Content-Type: application/json' \
-d '{"product":"OAuth Workshop","quantity":2}'
ผลที่คาดหวัง: 201 Created
#9. ตารางทดสอบ API
| กรณีทดสอบ | ผลที่คาดหวัง |
|---|---|
GET /health ไม่ส่ง Token |
200 |
GET /api/orders ไม่ส่ง Token |
401 |
| ส่ง Token รูปแบบผิดหรือแก้ลายเซ็น | 401 |
| Token หมดอายุเกิน Clock Tolerance 5 วินาที | 401 |
| Token จาก Issuer อื่น | 401 |
| Token มี Audience อื่น | 401 |
อ่านด้วย orders:read |
200 |
เขียนด้วย Token ที่มีเฉพาะ orders:read |
403 |
เขียนด้วย orders:write และข้อมูลถูกต้อง |
201 |
เขียนด้วยสิทธิ์ถูกต้อง แต่ quantity เป็น 0 |
400 |
| ส่ง JSON ไม่ถูกต้อง | 400 |
| ตัวตรวจ Token ดึง JWKS ไม่ได้และไม่มี Key ใน Cache | 503 |
ตัวอย่างกรณีไม่ส่ง Token และ Token ผิดรูปแบบ
curl -i http://localhost:3000/api/orders
curl -i http://localhost:3000/api/orders -H 'Authorization: Bearer invalid.token'
Token ที่ถูกแก้ไขต้องถูกปฏิเสธก่อน Route Handler แม้ว่าจะอ้าง Scope ครบก็ตาม
#10. ต่อด้วยผู้ใช้ล็อกอิน: Authorization Code + PKCE
เมื่อมีผู้ใช้ ให้สร้าง Public Client แยกจาก workshop-machine เช่น workshop-user และทดสอบด้วยเครื่องมือที่รองรับ Authorization Code + PKCE เช่น Postman Desktop
#10.1 ตั้งค่า Keycloak
| รายการ | ค่า |
|---|---|
| Client ID | workshop-user |
| Client authentication | Off |
| Standard flow | On |
| Direct access grants / Implicit flow | Off |
| PKCE Code Challenge Method | S256 |
| Valid redirect URIs | Callback URL ที่เครื่องมือแสดงจริง แบบตรงตัว |
ผูก express-api-audience แบบ Default และผูก orders:read แบบ Optional ก่อน ถ้าแอปไม่จำเป็นต้องเขียน อย่าผูก orders:write จากนั้นสร้างผู้ใช้ใน Realm express-demo และกำหนดรหัสผ่านสำหรับทดลอง
#10.2 ตั้งค่า OAuth ในเครื่องมือ
| รายการ | ค่า |
|---|---|
| Grant Type | Authorization Code (With PKCE) |
| Auth URL | http://localhost:8080/realms/express-demo/protocol/openid-connect/auth |
| Access Token URL | http://localhost:8080/realms/express-demo/protocol/openid-connect/token |
| Client ID | workshop-user |
| Client Secret | เว้นว่าง |
| Scope | openid orders:read |
| Code Challenge Method | SHA-256 / S256 |
| Callback URL | ค่าเดียวกับ Redirect URI ที่ลงทะเบียน |
เครื่องมือต้องสร้าง code_verifier แบบสุ่ม, ส่ง code_challenge และจัดการตรวจ state ของรายการล็อกอิน อย่าตั้ง Code Verifier คงที่หรือใช้ Wildcard Redirect URI เพื่อแก้ปัญหาการตั้งค่า [6]
#10.3 ลำดับการทำงาน
- Client สร้าง Code Verifier และ Code Challenge พร้อมค่า State ที่ผูกกับรายการล็อกอิน
- Browser ไปยัง Authorization Endpoint โดยส่ง
response_type=codeและcode_challenge_method=S256 - ผู้ใช้ล็อกอินที่ Keycloak
- Keycloak ส่ง Authorization Code กลับ Callback URL
- Client ตรวจ Callback/State แล้วนำ Code กับ Code Verifier ไปแลก Token
- Client ใช้ Access Token เรียก Express API ส่วน ID Token ใช้ในบริบทของ Client
- Express ตรวจ Token และ Scope ด้วย Middleware เดิม
สำหรับแอปจริงควรใช้ OIDC Client Library ที่ดูแลการตรวจ ID Token, Issuer, State และ Nonce เมื่อใช้ แทนการเขียนขั้นตอนล็อกอินเอง หากใช้ Backend for Frontend สามารถเก็บ Token ฝั่ง Server และให้ Browser ใช้ Session Cookie ที่มี HttpOnly, Secure, SameSite พร้อมมาตรการ CSRF ที่เหมาะสม
#11. ข้อควรออกแบบก่อนใช้จริง
- สิทธิ์ระดับข้อมูล:
orders:readไม่ได้ยืนยันว่าผู้ใช้มีสิทธิ์ดู Order ทุกคน ต้องตรวจ Owner/Tenant หรือ Policy ด้วย - การจัดเก็บ: Orders ในตัวอย่างอยู่ในหน่วยความจำ Restart แล้วหาย เปลี่ยนเป็น Database เมื่อนำไปใช้งาน
- Token Lifetime: ใช้ Access Token อายุสั้น การตรวจ JWT ในเครื่องอย่างเดียวอาจยังยอมรับ Token จนหมดอายุหลังผู้ใช้ออกจากระบบหรือสิทธิ์ถูกเปลี่ยน ถ้าต้องเพิกถอนทันทีต้องออกแบบเพิ่มเติม
- Secret: อย่าใส่ Client Secret ใน SPA, Mobile App, Git หรือ Log ไม่บันทึก Token ทั้งตัว
- Transport: ระบบจริงใช้ HTTPS ทั้งการขอ Token และเรียก API; ถ้าใช้ Proxy ให้กำหนด Trusted Proxy ตาม Deployment จริง
- JWT Validation: ไม่เลือก Issuer/JWKS URL ตามข้อมูลที่ยังไม่ได้ตรวจใน Token และไม่ยอมรับ Algorithm ตามที่ Token ร้องขอเอง
- Browser: CORS ควบคุม Browser Origin แต่ไม่แทน Authorization; แยกการจัดการ Token และ CSRF ให้เหมาะกับสถาปัตยกรรม
- Operations: จำกัดอัตราการเรียก, บันทึกเหตุการณ์สำคัญแบบไม่เปิดเผย Token และอัปเดต Dependencies
#12. แก้ปัญหาที่พบบ่อย
| อาการ | ตรวจอะไร |
|---|---|
Token Endpoint ตอบ invalid_client |
Client ID/Secret และ Client authentication |
unauthorized_client |
เปิด Service accounts roles สำหรับ Machine Client แล้วหรือไม่ |
| มี Token แต่ API ตอบ 401 | iss, aud, exp, Algorithm และชนิด Token |
| มี Token แต่ API ตอบ 403 | เปิด Include in token scope, ผูก Scope และส่งชื่อ Scope ตอนขอ Token |
| API ตอบ 503 | Keycloak พร้อมใช้งานหรือไม่, JWKS URL และ Network |
PKCE ตอบ invalid_grant |
Code หมดอายุ/ถูกใช้ซ้ำ, Redirect URI และ Code Verifier |
| เปลี่ยนค่าจาก localhost เป็น 127.0.0.1 แล้ว 401 | Issuer ต้องตรงกัน รวม Scheme/Hostname/Port/Realm |
ถ้าใช้ Container สำหรับ Express ค่า localhost ภายใน Container จะไม่ใช่เครื่อง Host ต้องวางแผน Hostname ที่ทั้ง Client และ Resource Server เข้าถึงได้ โดยรักษา Issuer ให้สอดคล้องกัน
#13. แบบฝึกหัดต่อยอด
- สร้าง Client สำหรับอ่านอย่างเดียว โดยไม่ผูก
orders:writeแล้วตรวจว่าไม่สามารถขอสิทธิ์เขียนได้ - เพิ่ม PostgreSQL และ Endpoint
GET /api/orders/:idพร้อมตรวจ Ownership - เพิ่ม Integration Tests สำหรับ Token หมดอายุ, Audience ผิด, ลายเซ็นผิด และ Scope ขาด
- เชื่อม Frontend ด้วย Authorization Code + PKCE โดยใช้ OIDC Library
#14. หยุด Workshop
unset CLIENT_SECRET TOKEN_READ TOKEN_WRITE TOKEN_ENDPOINT
docker compose down
คำสั่งนี้หยุด Keycloak และเก็บ Volume ไว้ ใช้ Ctrl+C หยุด Express หากต้องการเริ่ม Realm ใหม่ทั้งหมดจึงค่อยลบ Volume โดยตระหนักว่าการตั้งค่าและผู้ใช้ของ Workshop จะถูกลบ
#15. สรุป
Express รับผิดชอบการตรวจ Access Token และบังคับสิทธิ์ของ API ส่วน Keycloak รับผิดชอบการออก Token และการยืนยันตัวตน การเลือก Flow ให้เหมาะกับผู้เรียกและตรวจทั้ง Signature, Issuer, Audience, เวลา และ Scope ทำให้ระบบควบคุมสิทธิ์ได้ชัดเจน เมื่อมีผู้ใช้ให้ต่อด้วย OIDC และ Authorization Code + PKCE พร้อมสิทธิ์ระดับข้อมูล
#แหล่งอ้างอิง
ตรวจสอบเอกสารวันที่ 13 ตุลาคม 2026 โค้ดและรูปแบบ Workshop เรียบเรียงขึ้นใหม่จากแนวคิดในเอกสารทางการ
- Keycloak: Getting started with Docker
- Express: Migrating to Express 5
- jose: JWT verification and supported versions
- OpenID Connect Core 1.0
- RFC 6750: OAuth 2.0 Bearer Token Usage
- RFC 9700: Best Current Practice for OAuth 2.0 Security
- jose: createRemoteJWKSet
- Keycloak: Client scopes
- Keycloak Server Administration Guide: Audience support and service accounts
#สถานะการตรวจสอบตัวอย่าง
ตรวจ Syntax และทดสอบ Express Middleware ผ่าน 18 กรณีด้วย JWT ที่ลงนามจริงและ JWKS จำลองในเครื่อง ครอบคลุม Token ถูกต้อง, Token หมดอายุ, Issuer/Audience ผิด, ลายเซ็นผิด, Algorithm ผิด, ชนิด Token ผิด, สิทธิ์ไม่พอ, ข้อมูลและ JSON ไม่ถูกต้อง และ JWKS ใช้งานไม่ได้ สภาพแวดล้อมจัดทำบทความไม่มี Docker จึงยังไม่ได้ทดสอบขั้นตอน Keycloak และ PKCE แบบ End-to-End