🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน
quay.io/keycloak/keycloak:26.7 # tag ลอย · ตอนที่ผมรันมันชี้ไป 26.7.2
Keycloak 26.7.2 on JVM (powered by Quarkus 3.33.3.1)
Docker client 29.6.2 · server 29.5.2 # รันผ่าน colima ไม่ใช่ Docker Desktop
docker-compose v5.3.1 # เรียกด้วยขีดกลาง (เหตุผลอยู่ในกล่องเตือนหัวข้อ 4)
go version go1.26.5 darwin/arm64 # ใช้ตอนพิสูจน์ verify ลายเซ็นในหัวข้อ 7
บทนี้ไม่ต้องเขียนโค้ด Go เลยจนถึงหัวข้อ 7 — ใช้แค่ docker-compose กับ curl
ถ้าจะทำตาม: สร้างโฟลเดอร์เปล่า ๆ หนึ่งอัน ใส่ compose.yml ที่จะเห็นในหัวข้อ 3
แล้วรันในนั้น
1 · Keycloak คืออะไร
หน้าปกของสไลด์เขียนไว้สี่บรรทัด ผมยกมาทั้งหมดเพราะทุกคำมีความหมาย:
Open Source
Identity and Access Management
"Add authentication to applications and secure services with minimum effort."
"No need to deal with storing users or authenticating users."
ก่อนไปต่อ ต้องแยกสองคำนี้ให้ขาดก่อน เพราะทั้งบทนี้วางอยู่บนมัน:
| คำ | แปลว่า | คำถามที่มันตอบ |
|---|---|---|
| Authentication (authn) | การพิสูจน์ว่าคุณเป็นใคร | "คนที่ยิง request เข้ามาคนนี้ คือ chef1 จริงไหม" |
| Authorization (authz) | การตัดสินว่าคุณทำอะไรได้บ้าง | "chef1 มีสิทธิ์ลบสูตรของคนอื่นไหม" |
Identity and Access Management (IAM) คือระบบที่ดูแลทั้งสองเรื่องรวมกัน —
เก็บบัญชีผู้ใช้ · ตรวจรหัสผ่าน · เก็บว่าใครมีสิทธิ์อะไร · แล้วออกใบรับรอง (token) ให้แอปเอาไปใช้ต่อ
Keycloak เป็น IAM แบบ open source ที่คุณรันเองบนเครื่องตัวเองได้
ไม่ต้องสมัครบริการใคร ไม่ต้องจ่ายรายเดือน
ในภาษาของบทที่แล้ว Keycloak ก็คือกล่อง Authorization Server ในแผนภาพ OAuth2 นั่นเอง
"No need to deal with storing users or authenticating users."
แปลตรง ๆ คือ: ฐานข้อมูลของคุณจะไม่มีตาราง users ที่เก็บ password อีกต่อไป
password ของผู้ใช้จะไม่เคยวิ่งผ่านแอปของคุณเลยสักครั้ง — ผู้ใช้กรอกที่หน้า login ของ Keycloak
แล้ว Keycloak ส่ง token กลับมาให้แอปคุณ
นี่คือเหตุผลที่บริษัทเลือกใช้ของแบบนี้: ความเสี่ยงเรื่อง password ย้ายออกไปจากโค้ดของคุณทั้งก้อน
— คุณทำ password หลุดไม่ได้ ในเมื่อคุณไม่เคยมีมัน
2 · ทำเองทั้งหมด vs ให้ Keycloak ทำ
สไลด์หน้า 39 เป็นตารางเทียบสองคอลัมน์ ผมยกมาครบทั้งหกแถว — ซ้ายคืองานที่คุณต้องเขียนเองถ้าไม่ใช้ IAM ขวาคือสิ่งที่ Keycloak มีมาให้:
| งานที่ต้องทำเอง (DIY) | Keycloak ทำให้ |
|---|---|
| เขียนหน้า login, register, ลืมรหัสผ่าน | มีให้พร้อมใช้ ปรับแต่งได้ |
| จัดการ token signing, rotation, refresh | จัดการให้อัตโนมัติ |
| ระบบ role/permission (RBAC) | มี UI จัดการ role ให้เลย ไม่ต้องเขียนเอง |
| Login ผ่าน Google/GitHub | เปิดใช้ social login ได้ด้วยการตั้งค่า ไม่ต้องเขียนโค้ดเชื่อมเอง |
| Multi-factor authentication | เปิด/ปิดได้จาก config |
| หลาย service ต้อง verify token | ทุก service ใช้ public key เดียวกันจาก Keycloak verify ได้เลย ไม่ต้องแชร์ secret |
คำใหม่ในตารางนี้มีสองคำ นิยามไว้ก่อน:
-
RBAC (Role-Based Access Control) = ระบบสิทธิ์ที่ผูกสิทธิ์ไว้กับ "บทบาท" ไม่ใช่กับตัวคน
แทนที่จะเขียนว่า "สมชายลบสูตรได้" คุณเขียนว่า "roleadminลบสูตรได้" แล้วค่อยแปะ roleadminให้สมชาย — วันที่สมชายลาออก คุณถอด role ออกที่เดียวจบ - Token signing / rotation = การเซ็นลายเซ็นดิจิทัลลงบน token เพื่อให้ผู้รับตรวจได้ว่าไม่มีใครแก้ระหว่างทาง · ส่วน rotation คือการเปลี่ยนกุญแจที่ใช้เซ็นเป็นระยะ ซึ่งถ้าทำเองต้องคิดเรื่อง "แล้ว token ใบเก่าที่เซ็นด้วยกุญแจเก่าจะยังใช้ได้ไหม" ให้ครบ
📌 แถวสุดท้ายคือแถวเดียวที่พิสูจน์ได้ด้วยการรัน — และเราจะพิสูจน์มันจริงในหัวข้อ 7
ห้าแถวแรกเป็นเรื่อง "มีฟีเจอร์ให้ไหม" ซึ่งเปิด UI ดูก็จบ
แต่แถวสุดท้ายเป็นข้ออ้างทางเทคนิคที่ผิดได้ — ผมจะดึง public key จาก Keycloak
แล้วเขียน Go ตรวจลายเซ็น token โดยไม่ใช้ client_secret เลย
ผลลัพธ์: จริง — แต่มีเงื่อนไขหนึ่งข้อที่สไลด์ไม่ได้บอก และมันทำให้คนพลาดกันบ่อยมาก
3 · compose.yml ของสไลด์ ทีละบรรทัด
สไลด์หน้า 40 ให้ compose มาทั้งไฟล์ (ส่วน postgres กับ redis เขาย่อเป็น ...
เพราะสอนไปแล้วใน บทเรียน 32):
services:
postgres:
...
redis:
...
keycloak:
image: quay.io/keycloak/keycloak:26.7
container_name: wongnok-keycloak
restart: always
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "8081:8080"
volumes:
# เก็บข้อมูลของ embedded database (dev-file) ไว้
- wongnok-keycloak-data:/opt/keycloak/data
volumes:
wongnok-pgdata:
wongnok-keycloak-data:
ทีนี้มาไล่ทีละบรรทัดว่ามันทำอะไร — และบรรทัดไหนไม่ทำอะไร:
| บรรทัด | ทำอะไร | สถานะที่ผมตรวจแล้ว |
|---|---|---|
image: quay.io/keycloak/keycloak:26.7 |
ดึง image ทางการของ Keycloak จาก quay.io (ไม่ใช่ Docker Hub) | ✅ tag มีจริง · มีทั้ง amd64 และ arm64 (Mac M-series ใช้ได้) |
command: ["start-dev", ...] |
สั่งให้ Keycloak รันในโหมด development | ⚠️ ใช้ได้ตอนเรียน · ห้ามใช้บน production (เหตุผลอยู่ในกล่องข้างล่าง) |
--import-realm |
ตั้งใจให้อ่านไฟล์ realm จาก /opt/keycloak/data/import ตอน start |
❌ ไม่ทำงานเลยในไฟล์นี้ และไม่ error ด้วย — พิสูจน์ในหัวข้อ 4 |
KC_BOOTSTRAP_ADMIN_USERNAMEKC_BOOTSTRAP_ADMIN_PASSWORD |
สร้างบัญชีแอดมินชั่วคราวให้ตอนบูตครั้งแรก เพื่อให้มีคนล็อกอินเข้า UI ได้ | ✅ ทำงานจริง · ล็อกพิมพ์ยืนยันว่าสร้างให้แล้ว |
KC_HOSTNAME: localhostถูกลบออกในฉบับ Post |
เคยบอก Keycloak ว่า "ชื่อที่คนภายนอกใช้เรียกฉันคืออะไร" — ค่านี้ถูกเอาไปประกอบเป็น URL ทุกอันที่ Keycloak ประกาศออกไป | 🔄 อาจารย์ลบบรรทัดนี้ทิ้งหลังเรียน — และมันเปลี่ยนพฤติกรรมจริง ดูหัวข้อ 5 |
KC_HOSTNAME_STRICT: "false" |
ตั้งใจให้ผ่อนกฎเรื่องการตรวจ hostname | 🔄 กลายเป็นบรรทัดที่มีผลจริง — พอไม่มี KC_HOSTNAME แล้ว ถ้าลบบรรทัดนี้ทิ้งด้วยยังรันได้ แต่ถ้าเปลี่ยนเป็น true container ตายทันที — ดูหัวข้อ 5 |
KC_HTTP_ENABLED: "true" |
อนุญาตให้รับ request แบบ http:// ธรรมดา (ไม่ต้องมีใบรับรอง TLS) |
⚠️ จำเป็นตอน dev · ห้ามใช้บน production |
ports: "8081:8080" |
ข้างใน container Keycloak ฟังที่ 8080 · เราเปิดออกมาข้างนอกที่ 8081 | ✅ ต่อได้ที่ http://localhost:8081 |
volumes: wongnok-keycloak-data:/opt/keycloak/data |
เก็บฐานข้อมูลฝังในตัวของ Keycloak ไว้นอก container | ✅ realm/user/group/role รอด docker restart จริง — พิสูจน์ในหัวข้อ 8 |
ทำไมต้อง 8081:8080 ไม่ใช่ 8080:8080
รูปแบบของ ports คือ ข้างนอก:ข้างใน —
ตัวหน้าคือพอร์ตบนเครื่องคุณ ตัวหลังคือพอร์ตข้างใน container
Keycloak ข้างใน container ฟังที่ 8080 เสมอ (ล็อกตอน start พิมพ์ยืนยัน:
Listening on: http://0.0.0.0:8080) เราแก้ตัวหลังไม่ได้ถ้าไม่ตั้งค่าเพิ่ม
แต่ 8080 บนเครื่องคุณถูกจองไว้ให้ API ของเราแล้ว —
ตั้งแต่ บทเรียน 37 เป็นต้นมา
ทุกตัวอย่างรัน r.Run(":8080")
ถ้าแมปเป็น 8080:8080 คุณจะรัน Keycloak กับ API พร้อมกันไม่ได้ —
ตัวที่ขึ้นทีหลังจะจองพอร์ตไม่ได้
🧭 จำเลขให้แม่น เพราะเดี๋ยวจะสับสน —
8080 คือ API ของเรา · 8081 คือ Keycloak
เดี๋ยวในหัวข้อ 6 จะเห็น claim ชื่อ allowed-origins ใน token มีค่า
http://localhost:8080 — นั่นคือที่อยู่ของแอปเรา ไม่ใช่ของ Keycloak
อย่าอ่านสลับกัน
1. start-dev — Keycloak เตือนเองในล็อกทุกครั้งที่บูต:
Running the server in development mode. DO NOT use this configuration in production.
โหมดนี้ใช้ฐานข้อมูล H2 แบบไฟล์เดียว ฝังอยู่ใน container
(เดี๋ยวหัวข้อ 8 จะเปิดให้ดูว่ามันคือไฟล์อะไร) · ไม่มี clustering · ปิดการตรวจความปลอดภัยหลายอย่างเพื่อให้เริ่มง่าย
ของจริงต้องใช้ start เฉย ๆ พร้อมชี้ไป PostgreSQL แยกต่างหาก
2. KC_HTTP_ENABLED: "true" — แปลว่า "ยอมรับ HTTP ที่ไม่ได้เข้ารหัส"
ลองคิดดูว่ามันเลวร้ายแค่ไหน: ทุก request ที่ผู้ใช้กรอก password
และทุก response ที่ Keycloak ส่ง token กลับมา จะวิ่งเป็นตัวอักษรเปล่า ๆ บนสาย
ใครดักกลางทางได้ก็อ่านได้หมด
ที่เปิดได้ตอน dev เพราะทุกอย่างอยู่บนเครื่องเดียวกัน ไม่ได้ออกเน็ตจริง
📌 เรื่อง volumes: ก้อนล่างสุด —
ชื่อ volume ที่เขียนในส่วน services (เช่น wongnok-keycloak-data)
ต้องไปประกาศไว้ในบล็อก volumes: ระดับบนสุดของไฟล์ด้วย
ไม่งั้น compose ไม่รู้จักชื่อนั้น
สไลด์หน้า 40 มีบล็อกนั้นครบแล้ว (บรรทัด 25–27) — ตรงนี้สไลด์ไม่ผิด
ที่ต้องระวังคือตอนคุณลอก: ถ้าจับเฉพาะก้อน keycloak: ไปวางในไฟล์อื่น
แล้วลืมยกบล็อกล่างสุดไปด้วย มันจะไม่ยอมขึ้นตั้งแต่แรก
4 · รันขึ้นจริง — และสามบรรทัดในล็อกที่ต้องอ่าน
สั่งขึ้น — compose พิมพ์เป็นคู่ทุกอย่าง (Creating แล้วค่อย Created)
ก้อนนี้คือ output เต็ม ๆ ไม่ได้ตัดบรรทัดไหนทิ้ง:
$ docker-compose up -d
Network kc_default Creating
Network kc_default Created
Volume kc_wongnok-keycloak-data Creating
Volume kc_wongnok-keycloak-data Created
Container wongnok-keycloak Creating
Container wongnok-keycloak Created
Container wongnok-keycloak Starting
Container wongnok-keycloak Started
ถ้าเครื่องคุณยังไม่มี image จะมีบรรทัด Image quay.io/keycloak/keycloak:26.7 Pulled
โผล่เพิ่มมาข้างบนสุด และรอบแรกจะช้ากว่านี้มาก (เวลาหมดไปกับการ pull ไม่ใช่การ start)
แล้ววนถามจนกว่ามันจะตอบ ด้วยการยิงไปที่ discovery endpoint (จะอธิบายว่ามันคืออะไรในหัวข้อ 5)
และดูแค่ HTTP status code
ใน URL มีคำว่า realms/master — ตอนนี้รู้แค่ว่า
realm = พื้นที่ผู้ใช้/สิทธิ์แยกอิสระหนึ่งชุด ก็พอ ส่วน master คือชุดตั้งต้นที่ Keycloak
แถมมาให้ (รายละเอียดเต็มอยู่ในตารางหัวข้อ 6):
$ for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
http://localhost:8081/realms/master/.well-known/openid-configuration)
[ "$code" = "200" ] && { echo "READY (HTTP $code)"; break; }
sleep 1
done
READY (HTTP 200) # ของผมได้ 200 ที่รอบที่ 5 (~5 วินาที)
sleep 1 ในลูปไม่ใช่ของประดับ
ผมลองตัด sleep ออกแล้ววน 90 รอบหลัง docker restart —
วนครบ 90 รอบโดยไม่เคยได้ 200 เลยสักครั้ง เพราะลูปเปล่า ๆ วิ่งจบในเสี้ยววินาที
ตอนที่ Keycloak ยังบูตไม่เสร็จ
ถ้าลูปไม่มี break ด้วย มันก็จะยิงต่อไปอีก 55 รอบทั้งที่พร้อมแล้ว
ทีนี้เปิดล็อกดู — สามบรรทัดนี้คือทั้งหมดที่ต้องอ่าน:
$ docker logs wongnok-keycloak | tail
2026-08-22 03:13:34,745 INFO [org.keycloak.services] (main)
KC-SERVICES0077: Created temporary admin user with username admin
2026-08-22 03:13:34,883 INFO [io.quarkus] (main)
Keycloak 26.7.2 on JVM (powered by Quarkus 3.33.3.1) started in 5.582s.
Listening on: http://0.0.0.0:8080
2026-08-22 03:13:34,883 INFO [io.quarkus] (main) Profile dev activated.
อ่านได้สี่เรื่อง:
· tag 26.7 ให้เวอร์ชันจริง 26.7.2 — เพราะ 26.7
เป็น floating tag คือ tag ที่ชี้ไปเวอร์ชันย่อยล่าสุดของสาย 26.7 เรื่อย ๆ
· บัญชีแอดมินชั่วคราวถูกสร้างแล้ว ตามที่ KC_BOOTSTRAP_ADMIN_* สั่ง
(เข้า UI ที่ http://localhost:8081 ด้วย admin/admin ได้เลย —
หน้าตาแบบสไลด์หน้า 41)
· Listening on: http://0.0.0.0:8080 ยืนยันว่าข้างใน container คือ 8080 จริง
· Profile dev activated — คือผลของ start-dev
สไลด์ฉบับก่อนเรียนตั้งสองบรรทัดนี้คู่กัน แล้ว Keycloak พิมพ์บอกเองตั้งแต่ต้นล็อกว่า บรรทัดที่สองถูกเมินทิ้ง:
$ docker logs wongnok-keycloak | head -20
2026-08-25 12:35:29,898 INFO [org.keycloak.url.HostnameV2ProviderFactory] (main)
If hostname is specified, hostname-strict is effectively ignored
ตอนนั้นผมสรุปว่า "ลบ KC_HOSTNAME_STRICT ทิ้งไปเลย ผลเหมือนเดิม"
ฉบับ Post ลบอีกบรรทัดหนึ่งแทน — ลบ KC_HOSTNAME: localhost
แล้วเก็บ KC_HOSTNAME_STRICT: "false" ไว้
ผลคือบรรทัดเตือนข้างบนหายไปจากล็อกทั้งบรรทัด (ผมรันเทียบแล้ว grep ไม่เจอเลย)
เพราะไม่มี hostname ให้ไปบังอีกต่อไป
ปัญหาเดิมคือ "สองบรรทัดนี้ตีกัน" ซึ่งแก้ได้ทั้งสองทาง —
ผมเสนอให้ลบตัวที่ถูกเมิน อาจารย์เลือกลบตัวที่ไปบัง
แต่สองทางนี้ให้ผลต่างกันมาก — ต่างพอที่ทางหนึ่งทำให้แอปคุณต่อ Keycloak ไม่ติด
ส่วนอีกทางต่อติด รายละเอียดอยู่หัวข้อ 5
--import-realm เงียบสนิท ไม่ import อะไรเลย
ผมไล่หาคำว่า import ในล็อกทั้งก้อน ได้แค่บรรทัดเดียว และมันไม่ใช่บรรทัด import:
$ docker logs wongnok-keycloak 2>&1 | grep -i -E "import|realm"
2026-08-22 03:13:34,071 INFO [org.keycloak.services] (main)
KC-SERVICES0050: Initializing master realm
Initializing master realm คือการสร้าง realm ตั้งต้นที่ Keycloak ทำให้ทุกครั้งอยู่แล้ว
ไม่เกี่ยวกับ --import-realm
เหตุผล: flag นี้อ่านไฟล์จาก /opt/keycloak/data/import
แต่ compose เรา mount แค่ volume เปล่า ๆ เข้าไปที่ /opt/keycloak/data
ในนั้นไม่มีไฟล์ realm JSON สักไฟล์
ที่แสบคือ มันไม่ error — ไม่มีคำเตือน ไม่มี exit code ผิด
คนอ่านสไลด์จะนึกว่า "มันคง import อะไรให้แล้วล่ะมั้ง" ทั้งที่ไม่มีอะไรเกิดขึ้นเลย
ถ้าอยากให้มันทำงานจริง ต้อง mount ไฟล์เข้าไป เช่น
- ./realm-export.json:/opt/keycloak/data/import/realm-export.json
🔧 สองเรื่องที่เกิดกับเครื่องผม ไม่ใช่ความผิดของสไลด์ แต่คุณอาจเจอ
· docker compose (แบบเว้นวรรค) ใช้ไม่ได้ ขึ้นว่า
docker: unknown command: docker compose เพราะ
~/.docker/cli-plugins/docker-compose เป็น symlink ค้างไปที่ Docker Desktop ที่ถูกลบไปแล้ว
ซึ่งไปบัง plugin ตัวจริง · แก้ด้วยการเรียก docker-compose (มีขีดกลาง) แทน
· ตอน pull ครั้งแรกพังด้วย
error getting credentials - err: exec: "docker-credential-osxkeychain": executable file not found in $PATH
ซึ่งเป็นซากการตั้งค่าจาก Docker Desktop เดิม
ทั้งสองอาการเกิดกับเครื่องที่เคยลง Docker Desktop แล้วถอดออกไปใช้ colima —
ถ้าเครื่องคุณลง Docker Desktop อยู่ตามปกติ จะไม่เจอ
5 · OIDC discovery — ที่อยู่ของทุก endpoint มาจากไฟล์เดียว
ก่อนอื่นนิยามศัพท์: OIDC (OpenID Connect) คือมาตรฐานที่ต่อยอดจาก OAuth2
โดยเพิ่มเรื่อง "แล้วผู้ใช้คนนี้เป็นใคร" เข้าไป (OAuth2 เพียว ๆ ตอบแค่ "แอปนี้มีสิทธิ์ทำอะไร")
ของที่ OIDC เพิ่มเข้ามาและเราจะได้เห็นเดี๋ยวนี้คือ id_token
กับ discovery endpoint
Discovery endpoint คือ URL มาตรฐานหนึ่งอันที่ทุก OIDC server ต้องมี — ตำแหน่งคือ
/.well-known/openid-configuration ต่อท้าย realm
ยิงเข้าไปแล้วมันจะคืน JSON ที่บอกที่อยู่ของทุก endpoint ที่เหลือ
แปลว่าคุณไม่ต้องจำ URL อะไรเลยนอกจากอันนี้อันเดียว:
$ curl -s http://localhost:8081/realms/master/.well-known/openid-configuration
issuer = "http://localhost:8081/realms/master"
authorization_endpoint = "http://localhost:8081/realms/master/protocol/openid-connect/auth"
token_endpoint = "http://localhost:8081/realms/master/protocol/openid-connect/token"
jwks_uri = "http://localhost:8081/realms/master/protocol/openid-connect/certs"
end_session_endpoint = "http://localhost:8081/realms/master/protocol/openid-connect/logout"
userinfo_endpoint = "http://localhost:8081/realms/master/protocol/openid-connect/userinfo"
introspection_endpoint = "http://localhost:8081/realms/master/protocol/openid-connect/token/introspect"
จับคู่กับแผนภาพ OAuth2 ในบทที่แล้วได้ตรง ๆ:
| field | ใครเรียก | เรียกไปทำอะไร |
|---|---|---|
authorization_endpoint | เบราว์เซอร์ของผู้ใช้ | ที่ที่เรา redirect ผู้ใช้ไปกรอก username/password — คือ "ขั้นที่ 2" ในแผนภาพ |
token_endpoint | backend ของเรา | เอา code ที่ได้กลับมา แลกเป็น access_token — "ขั้นที่ 5" |
jwks_uri | ทุก service ที่ต้อง verify | ดึง public key มาตรวจลายเซ็น token — พระเอกของหัวข้อ 7 |
userinfo_endpoint | backend | ถามข้อมูลผู้ใช้เพิ่มโดยยื่น access_token ให้ดู |
introspection_endpoint | backend | ถาม Keycloak ตรง ๆ ว่า "token ใบนี้ยังใช้ได้อยู่ไหม" |
end_session_endpoint | backend / เบราว์เซอร์ | logout — ยกเลิก session ฝั่ง Keycloak |
ในไฟล์เดียวกันยังบอกด้วยว่ารองรับวิธีขอ token แบบไหนบ้าง (grant_types_supported) —
ผมยกมาเฉพาะที่จะใช้ในบทนี้:
grant_types_supported = ["authorization_code", "client_credentials", "implicit",
"password", "refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:uma-ticket",
"urn:openid:params:grant-type:ciba"]
response_types_supported = ["code", "none", "id_token", "token", "id_token token",
"code id_token", "code token", "code id_token token"]
issuer — มันมีเลขพอร์ตติดมาด้วย
ดู issuer อีกรอบ: http://localhost:8081/realms/master
ทั้งที่ compose ไม่ได้บอก Keycloak เลยว่าตัวเองอยู่พอร์ตอะไร —
Keycloak เติมให้เองจาก request ที่วิ่งเข้ามา
ทำไมสำคัญ: เวลา verify token เราต้องเช็คว่า claim iss ใน token
ตรงกับ issuer ที่เราคาดไว้แบบเป๊ะทุกตัวอักษร
ถ้าโค้ดคุณเขียนค่าที่คาดไว้เป็น http://localhost/realms/wongnok (ลืมพอร์ต)
หรือ http://localhost:8080/... (ใส่พอร์ตของ API ตัวเอง)
token ทุกใบจะ verify ไม่ผ่าน ทั้งที่ token ถูกต้องทุกอย่าง
วิธีกันพลาด: อย่าพิมพ์เอง — ให้ library ดึงจาก discovery endpoint มาใช้
ลบ KC_HOSTNAME ทิ้งแล้วเกิดอะไรขึ้น — ผมรันเทียบสองตัวพร้อมกัน
ผมยกคอนเทนเนอร์ขึ้นมาสองตัว ต่างกันบรรทัดเดียว แล้วยิง discovery
ด้วย Host: header คนละแบบ เพื่อดูว่า issuer ที่ประกาศออกมาเปลี่ยนตามไหม:
# ฉบับก่อนเรียน — ปักหมุด hostname ไว้
$ docker run -d --name kcpre -p 8093:8080 \
-e KC_HOSTNAME=localhost -e KC_HOSTNAME_STRICT=false -e KC_HTTP_ENABLED=true \
quay.io/keycloak/keycloak:26.7 start-dev
# ฉบับ Post — ไม่ปักหมุด
$ docker run -d --name kcpost -p 8091:8080 \
-e KC_HOSTNAME_STRICT=false -e KC_HTTP_ENABLED=true \
quay.io/keycloak/keycloak:26.7 start-dev
$ curl -s -H "Host: <ที่ระบุในตาราง>" http://localhost:<พอร์ต>/realms/master/.well-known/openid-configuration \
| jq -r .issuer
ยิงด้วย Host: | kcpre — มี KC_HOSTNAME: localhost | kcpost — ไม่มี |
|---|---|---|
localhost:<พอร์ต> |
http://localhost:8093/realms/master |
http://localhost:8091/realms/master |
127.0.0.1:<พอร์ต> |
http://localhost:8093/realms/masterเปลี่ยนชื่อโฮสต์ให้เอง |
http://127.0.0.1:8091/realms/masterเชื่อตามที่ขอมา |
evil.example.com |
http://localhost/realms/masterไม่เชื่อ · แถมพอร์ตหายไปด้วย |
http://evil.example.com/realms/masterเชื่อทุกอย่างที่ขอมา |
อ่านตารางนี้ให้ออกคือเข้าใจทั้งเรื่อง:
· KC_HOSTNAME = "ไม่ว่าใครจะเรียกฉันว่าอะไร ฉันจะตอบว่าฉันชื่อ localhost"
· ไม่มี KC_HOSTNAME = "ใครเรียกฉันว่าอะไร ฉันก็ตอบตามนั้น"
ตอนคุณเปิดเบราว์เซอร์ที่ localhost:8081 ทั้งสองแบบทำงานเหมือนกันเป๊ะ
ปัญหาจะโผล่ตอนที่ Go API ของคุณย้ายเข้าไปอยู่ใน compose ด้วยกัน
แล้วเรียก Keycloak ด้วยชื่อ service (http://keycloak:8080) แทน localhost
ผมรันจริงจากในเน็ตเวิร์กเดียวกัน — เรียกด้วยชื่อคอนเทนเนอร์ ได้ issuer คนละอย่างเลย:
kcpre (ปักหมุด) http://kcpre:8080/... -> issuer = http://localhost:8080/realms/master
kcpost (ไม่ปักหมุด) http://kcpost:8080/... -> issuer = http://kcpost:8080/realms/master
แถวบน คือ เรียกไปที่ที่อยู่หนึ่ง แต่เขาตอบกลับมาว่าเขาอยู่อีกที่หนึ่ง
ไลบรารี OIDC ทุกตัวตรวจจุดนี้เป็นเรื่องแรก เพราะมันคือด่านกันการโดนหลอกให้ไปคุยกับเซิร์ฟเวอร์ปลอม
ผลคือ oidc.NewProvider() คืน error ตั้งแต่บรรทัดแรกของโปรแกรม ไม่ทันได้ล็อกอินเลยด้วยซ้ำ:
oidc: issuer URL provided to client ("http://127.0.0.1:8093/realms/master")
did not match the issuer URL returned by provider ("http://localhost:8093/realms/master")
นี่คือ error จริงที่ผมรันได้จาก github.com/coreos/go-oidc/v3
(ผมจำลองด้วยการเรียก kcpre ผ่าน 127.0.0.1 แทนชื่อคอนเทนเนอร์ —
กลไกเดียวกันเป๊ะ คือเรียกด้วยชื่อที่ไม่ตรงกับหมุดที่ปักไว้)
ส่วน kcpost ในเงื่อนไขเดียวกัน ผ่านฉลุย
นี่คือเหตุผลที่บรรทัดนั้นถูกลบทิ้ง — ไม่ใช่เรื่องความสวยงามของ config
บริษัทหนึ่งมีเบอร์ 2 เบอร์ เบอร์นอกไว้ให้ลูกค้า เบอร์ในไว้ให้แผนกอื่นโทรหากัน
พนักงานรับสายถูกสั่งมาว่า "รับสายทุกครั้งให้พูดว่า ‘สวัสดีค่ะ ที่นี่เบอร์นอกนะคะ’"
ลูกค้าโทรเข้าเบอร์นอก — ตรงกัน ไม่มีปัญหา
แต่แผนกบัญชีโทรเข้าเบอร์ใน แล้วได้ยินว่า "ที่นี่เบอร์นอกนะคะ"
บัญชีที่ทำตามระเบียบเป๊ะจะวางสายทันที เพราะกดเบอร์ในแต่ปลายสายยืนยันว่าเป็นอีกเบอร์ —
ตามระเบียบถือว่าต่อสายผิด
KC_HOSTNAME คือคำสั่ง "รับสายให้พูดว่าเบอร์นอกเสมอ" ·
oidc.NewProvider() คือแผนกบัญชีที่ทำตามระเบียบเป๊ะ
ที่ไม่เหมือนกัน: คนโทรผิดยังถามต่อได้ว่า "เอ๊ะ ที่นี่ต่อ 202 ได้ไหม" แล้วคุยกันรู้เรื่อง
แต่ oidc.NewProvider() ไม่มีโหมดถามต่อ — มันคืน error แล้วจบ
เพราะถ้ามันยอมคุยต่อ คนร้ายก็ตั้งเซิร์ฟเวอร์ปลอมมาหลอกได้ทันที
KC_HOSTNAME_STRICT: "false" ที่เหลืออยู่ ยังไร้ประโยชน์ไหม
เปลี่ยนสถานะแล้ว — เมื่อก่อนมันถูกเมินทิ้ง ตอนนี้มันถูกอ่านจริง ผมลองสามแบบ:
· ไม่ใส่บรรทัดนี้เลย → รันขึ้นปกติ (โหมด start-dev ถือว่า false อยู่แล้ว)
· ใส่ "false" → รันขึ้นปกติ — ผลเท่ากับไม่ใส่
· ใส่ "true" → คอนเทนเนอร์ตายทันที ด้วยข้อความตรง ๆ:
ERROR: hostname is not configured; either configure hostname, or set hostname-strict to false
สรุป: บรรทัดนี้ยังไม่ได้ทำอะไรในวันนี้ แต่มันคือตัวกันไว้ —
วันที่คุณย้ายขึ้น production (ที่ strict เป็น true โดยปริยาย) คุณจะต้องปักหมุด hostname ให้ถูก
ไม่ใช่ปิด strict ทิ้ง
เข้าห้างที่ไม่คุ้น เราไม่ได้ท่องจำว่าร้านไหนอยู่ชั้นไหน
เราจำอยู่อย่างเดียวว่า "ป้ายผังอยู่ตรงทางเข้า" แล้วเดินไปอ่านทุกครั้ง
วันไหนห้างย้ายร้าน ป้ายก็เปลี่ยนตาม เราไม่ต้องมาแก้ความจำของตัวเอง
OIDC คิดแบบเดียวกันเป๊ะ:
· /.well-known/openid-configuration = ตำแหน่งของป้ายผัง ที่อยู่ที่เดิมเสมอ
· token_endpoint · jwks_uri · end_session_endpoint
= ตำแหน่งร้านที่ป้ายบอก — จำเองไม่ไหว และไม่ต้องจำ
ที่ไม่เหมือนกัน: อ่านป้ายผังห้างพลาดนิดหน่อย เดินวนสักหน่อยก็เจอร้าน
แต่ค่า issuer ที่ผิดแค่เลขพอร์ตตัวเดียว
ทำให้ token ทุกใบ verify ไม่ผ่านทันที อย่างที่กล่องเตือนข้างบนเพิ่งบอก
ดังนั้น "อ่านจากป้าย" ในโลก OIDC ไม่ใช่แค่เรื่องสะดวก
แต่มันคือวิธีกันพิมพ์ผิด
- endpoint
- URL หนึ่งเส้นที่มีหน้าที่เฉพาะของมัน · พูดว่า "ยิงไปที่ token endpoint" สื่อกว่า "ยิงไปที่ URL ขอ token" · ถาม senior ได้ว่า “ระบบนี้ endpoint ไหนบ้างที่ต้องเรียกจาก backend เรา”
- discovery document /
.well-known - "ป้ายผัง" ของ OIDC — JSON ไฟล์เดียวที่บอกที่อยู่ของ endpoint ทุกอัน · พิมพ์ถาม AI ได้ว่า “load OIDC endpoints from the discovery document in Go instead of hardcoding them”
- issuer (claim
iss) - "ใครเป็นคนออก token ใบนี้" — เขียนเป็น URL เต็ม รวมเลขพอร์ตด้วย · ตอน verify ต้องเทียบเป๊ะทุกตัวอักษร · เวลา debug ให้ถามตรง ๆ ว่า “issuer ที่ Keycloak ออกมา กับค่าที่โค้ดเราคาดไว้ ตรงกันทุกตัวไหม”
- jwks_uri
- ช่องใน discovery ที่บอกว่า ไปโหลด public key ได้ที่ไหน · เดี๋ยวหัวข้อ 7 จะใช้ของจริง
- OIDC (OpenID Connect)
- ชั้นบาง ๆ ที่ต่อยอดจาก OAuth2 เพื่อตอบว่า "ผู้ใช้คนนี้เป็นใคร" · พูดให้ถูกว่า “เราใช้ OIDC บน Keycloak” ไม่ใช่ “ใช้ login ของ Keycloak”
6 · Keycloak components — ห้ากล่องที่ต้องแยกให้ออก
สไลด์หน้า 42 วาดกล่องซ้อนกันไว้ ผมวาดใหม่ให้เห็นทิศทางความสัมพันธ์ชัดขึ้น:
┌── Realm: wongnok ─────────────────────────────────────────┐
│ │ ← Realm = พื้นที่แยกอิสระ 1 ชุด
│ ┌── Clients ──────────────┐ ┌── Users ─────────────┐ │ ของใน realm หนึ่ง
│ │ client_id wongnok-api │ │ chef1 │ │ มองไม่เห็นอีก realm เลย
│ │ client_secret ****** │ │ chef1@wongnok.dev │ │
│ │ client scope = claims │ │ + password │ │ ← Clients = "แอปไหนขอ token"
│ └─────────────────────────┘ └──────────┬───────────┘ │ Users = "คนไหนล็อกอิน"
│ │ │
│ user เป็นสมาชิกของ │ │
│ ▼ │
│ ┌── Roles ────────────────┐ ┌── Groups ────────────┐ │
│ │ recipe:write │◄──┤ chefs │ │ ← ผูก role ไว้ที่ "group"
│ │ recipe:read │ │ admins │ │ ไม่ใช่ผูกทีละ user
│ └────────────┬────────────┘ └──────────────────────┘ │
│ │ │
└────────────────┼──────────────────────────────────────────┘
│
▼
access_token → realm_access.roles = ["recipe:write", ...]
↑ role โผล่ที่นี่ ไม่ใช่ที่ resource_access
| กล่อง | คืออะไร | ตัวอย่างในโปรเจกต์ wongnok |
|---|---|---|
| Realm | พื้นที่แยกอิสระของแต่ละโปรเจกต์ — มี user, client, role เป็นของตัวเองครบชุด และข้ามไปใช้ของ realm อื่นไม่ได้ | wongnok · (Keycloak มี realm ชื่อ master ติดมาให้ตั้งแต่แรก
ซึ่งเป็น realm สำหรับดูแลตัว Keycloak เอง ไม่ควรเอาแอปไปใส่ในนั้น) |
| Clients | แอปที่ลงทะเบียนไว้ว่าจะมาขอ token · มี client_id (ชื่อเปิดเผยได้)
และ client_secret (ความลับ เก็บฝั่ง server เท่านั้น) |
wongnok-api |
| Client scope | ตัวกำหนดว่า token ที่ออกให้ client นี้จะมี claim อะไรแปะไปด้วย — claim คือ "ข้อมูลหนึ่งชิ้นที่เขียนไว้ใน token" เช่น อีเมล ชื่อ role | scope profile พา name/preferred_username มา ·
scope email พา email มา |
| Users | บัญชีผู้ใช้ · login ด้วย username หรือ email + password ที่เก็บอยู่ฝั่ง Keycloak ไม่ใช่ฐานข้อมูลเรา | chef1 |
| Groups | รวม user เป็นกลุ่ม เพื่อจัดสิทธิ์เป็นชุดได้ที่เดียว | chefs, admins |
| Roles | ตัวสิทธิ์เอง · ผูกกับ Group แทนการผูกกับ User ทีละคน | recipe:write |
คำในตารางข้างบนจำแยกกันยาก เพราะมันไม่ใช่ของหลายอย่างที่วางเรียงกัน แต่มันซ้อนกันอยู่
ลองย้ายทั้งชุดไปไว้ในตึกออฟฟิศให้เช่าที่มีฝ่ายอาคารคอยออกคีย์การ์ด
(ฝ่ายอาคาร = Keycloak):
· Realm = ตึกหนึ่งตึก — ฝ่ายอาคารเจ้าเดียวดูแลได้หลายตึก
แต่ทะเบียนคนละเล่ม การ์ดของตึก A เอาไปแตะประตูตึก B ไม่ติด
· Users = คนที่มีชื่อในทะเบียนของตึกนั้น — chef1
· Groups = แผนก — ย้ายคนเข้าแผนกครั้งเดียว ไม่ต้องตามไปแก้สิทธิ์ทีละใบ
· Roles = สิทธิ์ที่การ์ดเปิดได้ เช่น "เข้าห้องเก็บของ"
— เขียนไว้ที่แผนก ไม่ใช่ไล่แปะทีละคน (นี่คือลูกศรที่หัวข้อถัดไปจะพิสูจน์)
· Clients = บริษัทผู้เช่าที่ทำเรื่องลงทะเบียนไว้ก่อน
ว่าจะเป็นคนเดินไปขอการ์ดแทนพนักงาน · มีเลขที่สัญญาที่บอกใครก็ได้ (client_id)
กับรหัสที่เก็บในเซฟ ห้ามหลุด (client_secret)
ที่ไม่เหมือนกัน — สองข้อ และทั้งสองข้อสำคัญ:
1. คีย์การ์ดตึกจริงใช้ได้เรื่อย ๆ จนกว่าจะไปแจ้งยกเลิก แต่ access_token
ที่ Keycloak ออกให้ในบทนี้หมดอายุเองใน 5 นาที
(เดี๋ยวจะเห็นว่า exp - iat = 300 วินาทีพอดี) — ออกแบบมาให้หมดอายุไว ๆ ตั้งแต่ต้น
2. เครื่องอ่านการ์ดต้องต่อสายกลับไปถามฐานข้อมูลกลางทุกครั้งที่แตะ
แต่ service ที่รับ access_token ตรวจเองได้ที่หน้างาน ไม่ต้องยิงถาม Keycloak เลย
— เหตุผลอยู่ในหัวข้อ 7
- realm
- "ตึกหนึ่งตึก" = พื้นที่แยกอิสระหนึ่งชุด ·
masterคือตึกของฝ่ายอาคารเอง อย่าเอาแอปไปใส่ · ถาม senior ว่า “โปรเจกต์นี้ใช้ realm ชื่ออะไร ไม่ใช่ master ใช่ไหม” - client /
client_id/client_secret - "แอปที่ลงทะเบียนไว้ว่าจะมาขอ token" · id เปิดเผยได้ · secret อยู่ฝั่ง server เท่านั้น · ประโยคที่ใช้ได้จริง: “ขอ client_id กับ client_secret ของ realm นี้หน่อย เอาไปใส่ env ฝั่ง backend”
- claim
- ข้อมูลหนึ่งชิ้นที่เขียนอยู่ใน token เช่น
email,preferred_username· พูดว่า “claim ไหนบอก username” ชัดกว่า “ฟิลด์ไหนบอก username” - client scope
- ตัวคุมว่า token ที่ออกให้ client นี้จะพา claim อะไรติดไปด้วย · พิมพ์ถาม AI ได้ว่า “which Keycloak client scope adds the email claim to the access token”
- realm role กับ client role
- role คนละชนิดกัน และไปโผล่คนละ claim — realm role อยู่ที่
realm_access.roles· client role อยู่ที่resource_access· เวลา middleware อ่าน role ไม่เจอ ให้ถามก่อนเลยว่า “อันนี้เป็น realm role หรือ client role”
ก่อนพิสูจน์ — ขอ token จริงมาดูก่อน
🛑 ถึงตรงนี้บทกระโดดข้ามขั้น — อ่านก่อน ·
หัวข้อ 4 กับ 5 เราเล่นแต่ realm master แต่ตั้งแต่บรรทัดล่างนี้จะยิงไปที่ realm
wongnok ด้วย client wongnok-api และ user chef1 —
ของสามอย่างนี้ยังไม่มีในเครื่องคุณ ต้องสร้างก่อน
ทำได้สองทาง: กดสร้างใน UI ตามภารกิจข้อ 6–7 ท้ายบท (แนะนำสำหรับรอบแรก
เพราะจะได้เห็นหน้าตาของ Keycloak) หรือยิง admin REST API ตามก้อนล่างนี้ —
ผมรันก้อนนี้จริงและ status code ที่ได้คือเลขที่พิมพ์กำกับไว้:
# 1) ขอ token ของแอดมินไว้ใช้สั่งงาน admin API
KC=http://localhost:8081
AT=$(curl -s -X POST $KC/realms/master/protocol/openid-connect/token \
-d client_id=admin-cli -d username=admin -d password=admin \
-d grant_type=password | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
H="Authorization: Bearer $AT"
# 2) realm -> 201
curl -s -X POST $KC/admin/realms -H "$H" -H 'Content-Type: application/json' \
-d '{"realm":"wongnok","enabled":true}'
# 3) client — publicClient + directAccessGrants ต้องเปิดทั้งคู่ -> 201
curl -s -X POST $KC/admin/realms/wongnok/clients -H "$H" -H 'Content-Type: application/json' \
-d '{"clientId":"wongnok-api","enabled":true,"publicClient":true,
"directAccessGrantsEnabled":true,"standardFlowEnabled":true,
"webOrigins":["http://localhost:8080"],"redirectUris":["http://localhost:8080/*"]}'
# 4) user chef1 พร้อม password -> 201
curl -s -X POST $KC/admin/realms/wongnok/users -H "$H" -H 'Content-Type: application/json' \
-d '{"username":"chef1","enabled":true,"emailVerified":true,
"firstName":"Chef","lastName":"One","email":"chef1@wongnok.dev",
"credentials":[{"type":"password","value":"chef1234","temporary":false}]}'
# 5) realm role + group (เอาไปผูกกันในหัวข้อถัดไป) -> 201, 201
curl -s -X POST $KC/admin/realms/wongnok/roles -H "$H" -H 'Content-Type: application/json' -d '{"name":"recipe:write"}'
curl -s -X POST $KC/admin/realms/wongnok/groups -H "$H" -H 'Content-Type: application/json' -d '{"name":"chefs"}'
⚠️ "publicClient": true ห้ามลืม — curl ข้างล่างไม่ได้ส่ง
client_secret ไปเลย ถ้า client เป็นแบบ confidential
(publicClient: false ซึ่งเป็นค่า default ของ Keycloak) คุณจะได้แบบนี้แทน
ผมลองแล้วจริง:
{"error":"unauthorized_client","error_description":"Invalid client or Invalid client credentials"}
พอมีของครบแล้วก็ขอ token ด้วย
password grant — คือการส่ง username/password ไปที่ token_endpoint ตรง ๆ
ไม่ผ่านหน้าเว็บ ซึ่งใช้ได้เฉพาะตอนทดสอบ
(ท่านี้ถูกถอดออกจาก OAuth 2.1 แล้ว และ RFC 9700 ซึ่งเป็น
Best Current Practice แนะนำว่าไม่ควรใช้ — ที่ใช้ตรงนี้เพราะมันเป็นวิธีเดียวที่ขอ token
ได้จาก command line โดยไม่ต้องเปิดเบราว์เซอร์ · ของจริงใช้ authorization code flow
ซึ่งอยู่ในบทเรียน 52):
$ curl -s -X POST http://localhost:8081/realms/wongnok/protocol/openid-connect/token \
-d client_id=wongnok-api -d username=chef1 -d password=chef1234 \
-d grant_type=password -d scope=openid
access_token = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ3eXZLU2Zx... [len 1346]
expires_in = 300
refresh_expires_in = 1800
refresh_token = eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJiMmZmZGIz... [len 694]
token_type = Bearer
id_token = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ3eXZLU2Zx... [len 1037]
not-before-policy = 0
session_state = grEBoqUUX084tjH4AEFbI5aJ
scope = openid profile email
⏱️ อายุ token ที่ได้จริงบน Keycloak 26.7 — จำเลขนี้ไว้
expires_in = 300 แปลว่า access_token อยู่ได้ 5 นาที
refresh_expires_in = 1800 แปลว่า refresh_token อยู่ได้ 30 นาที
ไม่ใช่ 1 ชั่วโมงตามที่หลายคนจำต่อ ๆ กันมา
ผลตามมาที่ต้องคิดตั้งแต่ออกแบบ: ผู้ใช้ที่เปิดหน้าเว็บทิ้งไว้เกิน 5 นาทีแล้วกดปุ่ม
จะเจอ 401 ถ้าคุณไม่ได้ทำ refresh flow ไว้
(อีกอย่าง: client ต้องเปิด directAccessGrantsEnabled: true ก่อน
ถึงจะใช้ password grant แบบนี้ได้)
เก็บ token สองใบลงไฟล์ไว้ก่อน — เดี๋ยวหัวข้อ 7 จะหยิบไปใช้ทั้งคู่
(at.txt เอาไป verify ลายเซ็น · rt.txt เอาไปแลก token ใบใหม่):
$ curl -s -X POST http://localhost:8081/realms/wongnok/protocol/openid-connect/token \
-d client_id=wongnok-api -d username=chef1 -d password=chef1234 \
-d grant_type=password -d scope=openid > tok.json
$ python3 -c "import json;d=json.load(open('tok.json'));open('at.txt','w').write(d['access_token']);open('rt.txt','w').write(d['refresh_token'])"
$ wc -c at.txt rt.txt
1346 at.txt
694 rt.txt
2040 total
ทายก่อนดู — เราขอ token ในนามของ client ที่ชื่อ wongnok-api
ใน JWT มี claim ชื่อ aud (ย่อจาก audience)
ซึ่งแปลว่า "token ใบนี้ตั้งใจให้ใครเอาไปใช้"
โค้ด verify ที่เห็นกันเกลื่อนอินเทอร์เน็ตเขียนแบบนี้:
if claims.Audience != "wongnok-api" {
return errors.New("wrong audience")
}
ทายว่า aud ที่ Keycloak ใส่มาจริง ๆ คืออะไร
และถ้าไม่ใช่ "wongnok-api" — แล้วจะรู้ได้ยังไงว่า token ใบนี้ออกให้ client ตัวไหน?
ถอด payload ของ access_token ออกมาได้แบบนี้ (ตัด base64 ออก เหลือ JSON ล้วน):
{
"exp": 1787368745,
"iat": 1787368445,
"jti": "onrtro:f1adacbf-bd71-072f-c8c8-b8a6d666cc03",
"iss": "http://localhost:8081/realms/wongnok",
"aud": "account",
"sub": "5da7f595-196f-44a6-8dfd-64306ce734d6",
"typ": "Bearer",
"azp": "wongnok-api",
"sid": "grEBoqUUX084tjH4AEFbI5aJ",
"acr": "1",
"allowed-origins": ["http://localhost:8080"],
"realm_access": { "roles": ["default-roles-wongnok", "offline_access", "uma_authorization"] },
"resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } },
"scope": "openid profile email",
"email_verified": true,
"name": "Chef One",
"preferred_username": "chef1",
"given_name": "Chef",
"family_name": "One",
"email": "chef1@wongnok.dev"
}
สองบรรทัดบนสุดคือ epoch วินาที (จำนวนเต็มล้วน ไม่มีนิพจน์คำนวณ) —
exp - iat = 300 พอดี ตรงกับ expires_in = 300 ที่ได้มาในผลลัพธ์ข้างบน
(ถ้าอยากเช็คเอง: python3 -c 'print(1787368745-1787368445)')
aud คือ "account" ไม่ใช่ "wongnok-api"
ถ้าเขียนโค้ดตรวจ aud == client_id ตามที่เห็นในตัวอย่างทั่วไป
token ทุกใบจะไม่ผ่านหมด — และ error ที่ได้จะบอกแค่ "invalid audience"
ซึ่งไม่ช่วยให้เดาถูกเลยว่าต้นเหตุอยู่ตรงไหน
ตัวที่บอกว่า token ออกให้ client ไหนคือ azp
(Authorized Party) ซึ่งเป็น "wongnok-api" ตรงตามที่เราขอ
ถ้าอยากให้ aud เป็น client id จริง ๆ ต้องไปเพิ่ม
audience mapper ใน Keycloak เอง — ไม่ได้มาให้เองโดยอัตโนมัติ
อีกจุดที่ควรสังเกต: email, preferred_username, name
โผล่มาเพราะเราขอ scope profile กับ email
(สังเกต field scope ในผลลัพธ์) — ซึ่ง Keycloak ให้มาเป็น default client scope อยู่แล้ว
แต่ถ้าวันหนึ่งมีคนไปปิด scope พวกนี้ทิ้ง โค้ดที่อ่าน preferred_username
จะได้ค่าว่างทันที โดยที่ token ยัง verify ผ่านทุกอย่าง
สาม claim แรกข้างล่างนี้ตอบคำถามคนละข้อกันคนละเรื่อง แต่ชื่อสั้น ๆ สามตัวอักษรเหมือนกันหมด เลยจำสลับกันบ่อย · ตัวสุดท้ายเป็นชื่อการตั้งค่าที่เกี่ยวข้อง ใส่ไว้ให้ครบชุด:
sub(subject)- "token ใบนี้พูดถึงคนไหน" — เป็นรหัสประจำตัวของ user ที่ Keycloak ตั้งให้
(ในผลข้างบนคือสตริงยาว ๆ ขึ้นต้นด้วย
5da7f595) และมันไม่เปลี่ยนแม้ user จะเปลี่ยนชื่อหรืออีเมล · ประโยคที่ใช้ได้จริง: “ฝั่งเราควรเก็บ sub เป็น key ของ user ไม่ใช่เก็บ email” azp(authorized party)- "แอปไหนเป็นคนไปขอ token ใบนี้มา" — ค่านี้แหละที่เท่ากับ
client_idของเรา · ถาม senior ว่า “ถ้าจะเช็คว่า token มาจาก client ตัวไหน ต้องอ่าน azp ใช่ไหม” aud(audience)- "token ใบนี้จ่าหน้าถึงใคร" — คือปลายทางที่ตั้งใจให้เอาไปใช้ ·
ของ Keycloak default ได้
"account"ไม่ใช่ชื่อ client · พิมพ์ถาม AI ได้ว่า “how to add an audience mapper in Keycloak so aud contains my API client id” - audience mapper
- ตัวตั้งค่าใน Keycloak ที่ยัดค่าที่เราต้องการลงใน
aud· ถ้าไม่ตั้ง ก็อย่าเขียนโค้ดเช็คaud == client_idเพราะจะไม่ผ่านทุกใบ
พิสูจน์ลูกศร "user ใน group ได้ role อัตโนมัติ"
นี่คือข้ออ้างที่วาดเป็นลูกศรไว้ในสไลด์หน้า 42 และมันเป็นข้ออ้างที่ตรวจได้ตรง ๆ
ผมทำสามขั้น: สร้าง role recipe:write · สร้าง group chefs ·
แล้วผูก role เข้า group (ไม่ใช่เข้า user) จากนั้นเอา chef1 ใส่ group
ทายก่อนรัน — คำสั่งที่ผมยิงมีแค่สองอันนี้ ทั้งคู่ตอบ HTTP 204 (สำเร็จ ไม่มีเนื้อหาตอบกลับ)
# 1. เอา role recipe:write ไปแปะที่ "group" ชื่อ chefs
POST /admin/realms/wongnok/groups/$GRP/role-mappings/realm
# 2. เอา user chef1 ใส่เข้า group ชื่อ chefs
PUT /admin/realms/wongnok/users/$USR/groups/$GRP
สังเกตว่าไม่มีคำสั่งไหนเลยที่ผูก recipe:write เข้ากับ chef1 โดยตรง
ทายสองข้อ:
1. ถ้าถาม Keycloak ว่า "role ที่ผูกกับ chef1 โดยตรง มีอะไรบ้าง" จะเห็น recipe:write ไหม
2. แล้วถ้าขอ access_token ใบใหม่ recipe:write จะโผล่ใน token ไหม
และโผล่ที่ claim ไหน
== 3. map role recipe:write -> group chefs ==
HTTP 204
== 4. put user chef1 into group chefs ==
HTTP 204
== 5a. DIRECT realm roles of chef1 ==
['default-roles-wongnok']
== 5b. COMPOSITE/effective realm roles of chef1 ==
['default-roles-wongnok', 'offline_access', 'recipe:write', 'uma_authorization']
== 5c. groups of chef1 ==
['/chefs']
== access_token payload AFTER group->role ==
"azp": "wongnok-api",
"preferred_username": "chef1",
"realm_access": { "roles": ["default-roles-wongnok", "recipe:write",
"offline_access", "uma_authorization"] }
ข้ออ้างในสไลด์ถูกต้อง และนี่คือหลักฐาน:
· 5a — role ที่ผูกกับ chef1 โดยตรงมีแค่ default-roles-wongnok
เท่านั้น ไม่เคยมี recipe:write เลย
· 5b — แต่พอถาม "role ที่มีผลจริง" (effective / composite)
recipe:write โผล่มา เพราะได้มาผ่าน group
· ท้ายสุด — recipe:write อยู่ใน access_token ของจริง
🧭 จุดที่สไลด์ไม่ได้บอก และทำให้คนหาไม่เจอ:
role มาอยู่ที่ realm_access.roles (เพราะเราสร้างเป็น realm role)
ไม่ใช่ resource_access — ช่องนั้นเก็บ client role
ซึ่งเป็น role คนละชนิด (ในผลข้างบน resource_access มีแต่ role ของ client
ชื่อ account ที่ Keycloak แถมมาให้)
ถ้า middleware ของคุณไปอ่านผิดช่อง มันจะเห็น role ว่างเปล่าแล้วปฏิเสธทุก request
โดยที่ token ถูกต้องทุกอย่าง
7 · พิสูจน์แถวสุดท้าย — public key ใบเดียว verify ได้จริงไหม
กลับมาที่ข้ออ้างในตารางหัวข้อ 2:
"ทุก service ใช้ public key เดียวกันจาก Keycloak verify ได้เลย ไม่ต้องแชร์ secret"
ทำไมข้อนี้ถึงเป็นเรื่องใหญ่: ถ้าจริง แปลว่า service ที่ 2, 3, 4 ในระบบคุณ
ตรวจ token ได้เองโดยไม่ต้องถือความลับอะไรเลย และไม่ต้องยิงถาม Keycloak ทุก request
ที่อยู่ของ public key อยู่ในไฟล์ discovery แล้ว — คือ field jwks_uri
JWKS ย่อจาก JSON Web Key Set = ชุดกุญแจในรูปแบบ JSON
ทายก่อนรัน — ยิงไปที่ jwks_uri ของ realm wongnok
$ curl -s http://localhost:8081/realms/wongnok/protocol/openid-connect/certs
จะได้กุญแจกี่ใบ?
และถ้าโค้ดตัวอย่างที่คุณลอกมาเขียนว่า key := jwks.Keys[0] —
มันปลอดภัยไหม
number of keys = 2
{'kid': 'wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbO...', 'kty': 'RSA',
'alg': 'RS256', 'use': 'sig', 'x5t': 'or42Mu3VYD2f5W9Av5ynyyZmCFs',
'n': 'vJgLqZ6CjF23xnuZ3V8bOc9FP7iJqzE_QxTfQcjd...', 'e': 'AQAB'}
{'kid': 'Mf4wHVuGfb9hhl_aZu4jyayOm94BXmiUsDX413Re...', 'kty': 'RSA',
'alg': 'RSA-OAEP', 'use': 'enc', 'x5t': 'roKarAk6jTub0Z7KvbZJ0f9eQWI',
'n': 'rRa7SOuWAVLgWZmNK0qfzRuSK93hfN-ICNvDl8Kg...', 'e': 'AQAB'}
ได้สองใบ ไม่ใช่ใบเดียว — และมันคนละหน้าที่กัน:
· use: "sig" + alg: "RS256" = ใบสำหรับตรวจลายเซ็น ← อันที่เราต้องใช้
· use: "enc" + alg: "RSA-OAEP" = ใบสำหรับเข้ารหัส คนละงานกันเลย
ดังนั้น Keys[0] อันตราย — ลำดับในอาร์เรย์ไม่มีอะไรรับประกัน
และมันจะเปลี่ยนได้เมื่อ Keycloak หมุนกุญแจ (rotation)
วิธีที่ถูกคือ: อ่าน kid จาก header ของ JWT ที่ได้มา
แล้วไปหาใบที่ kid ตรงกันใน JWKS — kid ย่อจาก key id
คือหมายเลขกำกับกุญแจที่ผู้เซ็นแปะมาให้เราหาถูกใบพอดี
ทีนี้พิสูจน์ของจริง — ผมเขียนโปรแกรม Go ด้วย standard library ล้วน ๆ
(ไม่ใช้ library JWT ตัวไหนเลย เพื่อให้เห็นว่าไม่มีอะไรซ่อนอยู่):
ดึง JWKS → หาใบที่ kid ตรง → ประกอบเป็น rsa.PublicKey จากค่า n กับ e
→ เรียก rsa.VerifyPKCS1v15
นี่คือทั้งไฟล์ ไม่ได้ตัดอะไรออก — สร้างโฟลเดอร์ใหม่ verify/
ข้าง ๆ at.txt แล้ว go mod init kcverify ก่อน จากนั้นวางเป็น
verify/main.go:
// verify/main.go — ตรวจลายเซ็น JWT ด้วย stdlib ล้วน ไม่มี dependency
package main
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"os"
"strings"
)
type jwk struct {
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
// JWT ใช้ base64url แบบไม่มี padding
func b64(s string) []byte {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
panic(err)
}
return b
}
func main() {
// arg 1 = ไฟล์ที่เก็บ access_token · arg 2 = jwks_uri
raw, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
parts := strings.Split(strings.TrimSpace(string(raw)), ".")
var hdr struct{ Kid, Alg string }
json.Unmarshal(b64(parts[0]), &hdr)
fmt.Printf("token header kid = %s alg = %s\n", hdr.Kid, hdr.Alg)
resp, err := http.Get(os.Args[2])
if err != nil {
panic(err)
}
defer resp.Body.Close()
var set struct{ Keys []jwk }
json.NewDecoder(resp.Body).Decode(&set)
// หาใบที่ kid ตรง — ห้ามใช้ Keys[0]
var key *jwk
for i := range set.Keys {
if set.Keys[i].Kid == hdr.Kid {
key = &set.Keys[i]
}
}
if key == nil {
fmt.Println("no matching kid in JWKS")
os.Exit(1)
}
fmt.Printf("matched JWKS key: kid = %s use = %s alg = %s\n", key.Kid, key.Use, key.Alg)
// ประกอบ rsa.PublicKey จาก n (modulus) กับ e (exponent) ที่เป็น base64url
e := 0
for _, c := range b64(key.E) {
e = e<<8 | int(c)
}
pub := &rsa.PublicKey{N: new(big.Int).SetBytes(b64(key.N)), E: e}
fmt.Printf("public key modulus bits = %d exponent = %d\n", pub.N.BitLen(), pub.E)
// ลายเซ็นเซ็นทับสตริง "header.payload" ตรง ๆ
signed := parts[0] + "." + parts[1]
sum := sha256.Sum256([]byte(signed))
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], b64(parts[2])); err != nil {
fmt.Println("VERIFY FAILED:", err)
os.Exit(1)
}
fmt.Println("VERIFY OK -> signature valid using ONLY the public key from jwks_uri (no client_secret used)")
// แก้ token หนึ่งบิตแล้วตรวจซ้ำ — ต้องไม่ผ่าน
bad := []byte(signed)
bad[len(bad)-1] ^= 0x01
badSum := sha256.Sum256(bad)
err = rsa.VerifyPKCS1v15(pub, crypto.SHA256, badSum[:], b64(parts[2]))
fmt.Println("tampered token correctly REJECTED:", err)
}
รันจากในโฟลเดอร์ verify/ โดยชี้ไปที่ at.txt ที่เก็บไว้ตอนหัวข้อ 6:
$ go run . ../at.txt http://localhost:8081/realms/wongnok/protocol/openid-connect/certs
token header kid = wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4 alg = RS256
matched JWKS key: kid = wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4 use = sig alg = RS256
public key modulus bits = 2048 exponent = 65537
VERIFY OK -> signature valid using ONLY the public key from jwks_uri (no client_secret used)
tampered token correctly REJECTED: crypto/rsa: verification error
✅ ข้ออ้างในสไลด์ถูก — และแข็งแรงกว่าที่เขียนไว้ด้วยซ้ำ
· ตรวจผ่านโดยไม่ได้ใช้ client_secret เลยแม้แต่ครั้งเดียว
· ลองแก้ token แล้วยิงซ้ำ — ถูกปฏิเสธ ด้วย
crypto/rsa: verification error
นี่คือเหตุผลที่ระบบหลาย service ใช้วิธีนี้: ความลับมีอยู่ที่เดียวคือใน Keycloak
ส่วน service อื่น ๆ ถือแค่ public key ซึ่งหลุดไปก็ไม่เป็นไร
เพราะเอาไปปลอม token ไม่ได้ (มันตรวจได้อย่างเดียว เซ็นไม่ได้)
ผมถอด header ของ token ทั้งสองใบที่ได้มาจากคำขอเดียวกันมาเทียบ:
== JWT header of access_token ==
{ "alg": "RS256", "typ": "JWT", "kid": "wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4" }
== refresh_token header ==
{ "alg": "HS512", "typ": "JWT", "kid": "b2ffdb37-5e0e-4ae7-b420-370b9638d5b1" }
คนละอัลกอริทึมกันคนละโลก:
· RS256 เป็นแบบ asymmetric — เซ็นด้วย private key ตรวจด้วย public key
(จึงแจก public key ให้ใครก็ได้)
· HS512 เป็นแบบ symmetric — เซ็นและตรวจด้วยความลับตัวเดียวกัน
ซึ่งความลับนั้นอยู่ที่ Keycloak เท่านั้น ไม่มีการแจกออกมา
ดังนั้น refresh_token เอา public key ไปตรวจไม่ได้ และไม่ควรพยายาม —
มันไม่ได้ถูกออกแบบมาให้ service อื่นอ่าน ต้องส่งกลับไปแลกที่ token_endpoint
ของ Keycloak เท่านั้น
สรุปให้ชัด: ข้ออ้าง "verify ด้วย public key ไม่ต้องแชร์ secret" ใช้ได้กับ
access_token และ id_token — ไม่รวม refresh_token
ตรงนี้มีของสองแบบที่ทำงานคนละหลักการ แต่เรียกรวม ๆ ว่า "เซ็น" เหมือนกัน เลยพากันงง
แบบที่ 1 — ตัวอย่างลายเซ็น (นี่คือ RS256)
ตอนเปิดบัญชี เราเซ็นชื่อไว้เป็น "ตัวอย่างลายเซ็น" แล้วธนาคารส่งสำเนาไปให้ทุกสาขาเก็บไว้
ปากกาที่เซ็นได้จริงมีที่เดียวคือมือเรา แต่สำเนาตัวอย่างแจกได้ทั่วประเทศ
เพราะคนถือทำได้อย่างเดียวคือ "เอามาเทียบ" — เซ็นแทนเราไม่ได้
· ปากกาของเรา = private key · อยู่ใน Keycloak ที่เดียว ไม่เคยออกมา
· สำเนาที่แจกทุกสาขา = public key · คือสิ่งที่ทุก service โหลดจาก jwks_uri
· ธนาคารเก็บตัวอย่างลายเซ็นไว้หลายแผ่น (คนละคน · คนละหน้าที่ · ของเก่าที่ยังไม่ทิ้ง)
จึงต้องมีเลขกำกับแผ่น = kid ที่บอกว่าใบนี้ต้องเอาไปเทียบกับแผ่นไหน
— เหมือนที่เพิ่งเห็นว่า JWKS ของ realm นี้มีสองใบ และมีใบเดียวที่เป็น use: "sig"
แบบที่ 2 — รหัสตู้เซฟ (นี่คือ HS512)
รหัสตู้เซฟใช้เลขชุดเดียวกันทั้งล็อกและปลด จะเอาไปแจกสาขาอื่นไม่ได้เลย
เพราะแจกเมื่อไหร่ก็เท่ากับยกตู้ให้เขาไปด้วย
นี่คือเหตุผลที่ refresh_token ซึ่งเซ็นด้วย HS512
ตรวจเองที่ service เราไม่ได้ — ต้องส่งกลับไปให้ Keycloak ตรวจให้เท่านั้น
ที่ไม่เหมือนกัน: ลายเซ็นบนกระดาษเซ็นทับแค่ท้ายเอกสาร
ใครแอบแก้ตัวเลขกลางหน้าก็ยังดูไม่ออกจากลายเซ็น
แต่ลายเซ็นแบบ RS256 คำนวณทับข้อความทั้งฉบับ —
แก้ไปแค่บิตเดียวก็ตรวจไม่ผ่านทันที ซึ่งคือสิ่งที่บรรทัด
tampered token correctly REJECTED ข้างบนเพิ่งพิสูจน์ให้ดูกับตา
- asymmetric / public-key cryptography
- กุญแจมาเป็นคู่และคนละหน้าที่ — เซ็นด้วยใบลับ ตรวจด้วยใบเปิด ·
RS256เป็นแบบนี้ · ประโยคที่ใช้ได้จริง: “access_token เป็น RS256 ใช่ไหม เราจะได้ verify เองที่ service ไม่ต้องยิงถาม Keycloak” - symmetric / shared secret
- ความลับตัวเดียวใช้ทั้งเซ็นและตรวจ ·
HS512เป็นแบบนี้ · แจกไม่ได้โดยธรรมชาติ · ถ้ามีคนเสนอให้เอา secret ไปแจกทุก service ให้ถามกลับว่า “แล้ววันที่มัน leak เราจะรู้ได้ยังไงว่ารั่วจากตัวไหน” - JWKS (JSON Web Key Set)
- ชุด public key ในรูป JSON ที่ Keycloak ประกาศไว้ที่
jwks_uri· พิมพ์ถาม AI ได้ว่า “fetch JWKS and pick the key by kid in Go, not Keys[0]” kid(key id)- เลขกำกับกุญแจที่ผู้เซ็นแปะมาใน header ของ JWT เพื่อให้เราหยิบถูกใบ · เวลา verify พังให้ถามข้อแรกเลยว่า “kid ใน token หาเจอใน JWKS ไหม”
- key rotation
- การเปลี่ยนกุญแจที่ใช้เซ็นเป็นระยะ · เป็นเหตุผลว่าทำไม ห้าม hardcode key
และห้ามใช้
Keys[0]— วันที่หมุนกุญแจ ลำดับในอาร์เรย์เปลี่ยนได้ · ถาม senior ว่า “ฝั่งเรา cache JWKS ไว้นานแค่ไหน แล้วตอน rotate จะโหลดใหม่เองไหม”
ส่วนวิธีใช้ refresh_token ที่ถูกต้องคือแบบนี้ — ยิงกลับไปที่ Keycloak:
$ curl -s -X POST http://localhost:8081/realms/wongnok/protocol/openid-connect/token \
-d client_id=wongnok-api -d grant_type=refresh_token \
--data-urlencode refresh_token=$(cat rt.txt) \
| python3 -c "import sys,json;d=json.load(sys.stdin);print('keys:',list(d.keys()));print('new access_token len:',len(d['access_token']))"
keys: ['access_token', 'expires_in', 'refresh_expires_in', 'refresh_token',
'token_type', 'id_token', 'not-before-policy', 'session_state', 'scope']
new access_token len: 1366
🔎 id_token ติดมาด้วยเพราะตอนขอใบแรกเราใส่ scope=openid —
ลองเทียบเอง: ขอ token โดยไม่ใส่ scope=openid แล้ว refresh
จะได้ลิสต์ที่ไม่มี id_token และ
new access_token len: 1357 แทน (สั้นลง 9 ตัวอักษร)
เป็นจุดที่หลอกคนได้ง่ายมาก: โค้ดที่อ่าน id_token จาก response ของ refresh
จะพังทันทีถ้าใครไปลบ scope=openid ออกจากคำขอแรก
8 · ข้อมูลรอด restart ไหม + เก็บกวาด
บรรทัด volumes: ใน compose มีไว้เพื่ออะไร — พิสูจน์ง่ายมาก
ก่อนอื่นเปิดดูว่าข้างในเก็บอะไรไว้จริง:
$ docker exec wongnok-keycloak ls -la /opt/keycloak/data/h2
total 1720
drwxr-xr-x 2 keycloak root 4096 Aug 22 04:41 .
drwxrwxr-x 4 keycloak root 4096 Aug 22 04:41 ..
-rw-r--r-- 1 keycloak root 1683456 Aug 22 04:42 keycloakdb.mv.db
-rw-r--r-- 1 keycloak root 69091 Aug 22 04:41 keycloakdb.trace.db
ตัวเลขขนาดไฟล์ของคุณจะไม่ตรงกับของผม — มันโตตามจำนวน realm/user/role ที่สร้างไว้ ที่ต้องตรงคือชื่อไฟล์สองไฟล์นี้
นี่คือฐานข้อมูล H2 — ฐานข้อมูลที่ฝังมากับตัว Keycloak ในโหมด start-dev
ทั้ง realm ทั้ง user ทั้ง role อยู่ในไฟล์ keycloakdb.mv.db ไฟล์เดียวนี้
ทีนี้ restart แล้วขอ token ใหม่:
$ docker restart wongnok-keycloak && curl -s -o /dev/null \
--retry 30 --retry-delay 2 --retry-all-errors --retry-connrefused \
-w 'ready again (HTTP %{http_code})\n' \
http://localhost:8081/realms/wongnok/.well-known/openid-configuration
ready again (HTTP 200)
$ curl -s -X POST http://localhost:8081/realms/wongnok/protocol/openid-connect/token \
-d client_id=wongnok-api -d username=chef1 -d password=chef1234 \
-d grant_type=password -d scope=openid \
| python3 -c "import sys,json,base64; d=json.load(sys.stdin); p=json.loads(base64.urlsafe_b64decode(d['access_token'].split('.')[1]+'==')); print('preferred_username =',p['preferred_username']); print('realm_access.roles =',p['realm_access']['roles'])"
preferred_username = chef1
realm_access.roles = ['default-roles-wongnok', 'recipe:write', 'offline_access', 'uma_authorization']
--retry-all-errors ไม่ใช่แค่ --retry-connrefused
docker restart คืนค่าทันทีที่ container ถูกสตาร์ท ไม่ได้รอให้ Keycloak
บูตเสร็จ (ล็อกบอกเองว่า started in 5.582s) — ถ้ายิง curl ต่อท้ายด้วย
&& เฉย ๆ จะโดนปฏิเสธแน่นอน
และระหว่าง restart พอร์ต 8081 ยังเปิดอยู่ (docker ถือพอร์ตไว้) การเชื่อมต่อจึง
ไม่ใช่ connection refused แต่เป็นการโดนตัดกลางคัน — ผมลองใส่แค่
--retry-connrefused อย่างเดียวได้ ready again (HTTP 000)
คือ curl ไม่ยอม retry ให้
--retry-all-errors คือตัวที่ทำให้มัน retry ทุกความผิดพลาด แล้วถึงได้ HTTP 200
realm · user · group · role · การผูก role ผ่าน group — รอดครบทุกอย่าง
ถ้าไม่ mount volume ไว้ ทุกอย่างที่คุณคลิกสร้างใน UI จะหายหมดทุกครั้งที่ container ถูกสร้างใหม่
เสร็จแล้วเก็บกวาด — -v คือสั่งให้ลบ volume ไปด้วย:
$ docker-compose down -v
Container wongnok-keycloak Stopping
Container wongnok-keycloak Stopped
Container wongnok-keycloak Removing
Container wongnok-keycloak Removed
Network kc_default Removing
Volume kc_wongnok-keycloak-data Removing
Volume kc_wongnok-keycloak-data Removed
Network kc_default Removed
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
(ว่างเปล่า ไม่มีแถวเลย)
🧹 สิ่งที่ down -v ไม่ได้ลบ คือ image —
quay.io/keycloak/keycloak:26.7 ยังคาอยู่ในเครื่อง ถามดูได้เลย:
$ docker images quay.io/keycloak/keycloak
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
quay.io/keycloak/keycloak:26.7 831330513f55 756MB 266MB U
สองตัวเลขนี้คนละความหมาย: CONTENT SIZE 266MB คือขนาด layer ที่ดึงมา
ส่วน DISK USAGE 756MB คือที่มันกินบนดิสก์จริงหลังแตกไฟล์ออกมา —
เวลาคนพูดว่า "image ใหญ่แค่ไหน" มักหมายถึงเลขหลัง
ถ้าอยากลบด้วย: docker rmi quay.io/keycloak/keycloak:26.7
แต่ปกติควรเก็บไว้ เพราะครั้งหน้าจะได้ไม่ต้อง pull ใหม่ —
รอบแรกใช้เวลาไปกับการ pull ส่วนตัว Keycloak เอง start แค่ 5.582 วินาที
สรุปสิ่งที่สไลด์บอกครบ และสิ่งที่ต้องเติมเอง
| สิ่งที่สไลด์บอก | ผลตรวจจริง |
|---|---|
image: quay.io/keycloak/keycloak:26.7 |
✅ ถูก · tag มีจริง มี arm64 · ได้เวอร์ชันจริง 26.7.2 |
| ตาราง 5 แถวแรก (login page, signing, RBAC, social, MFA) | ✅ เป็นฟีเจอร์ที่มีจริงใน Keycloak |
| "ทุก service ใช้ public key เดียวกัน verify ได้ ไม่ต้องแชร์ secret" | ✅ ถูก · พิสูจน์ด้วย Go แล้ว แต่ใช้กับ refresh_token ไม่ได้ (HS512) |
| "user ใน group ได้ role อัตโนมัติ" (หน้า 42) | ✅ ถูก · เติมให้: role โผล่ที่ realm_access.roles ไม่ใช่ resource_access |
บล็อก volumes: ล่างสุดของ compose |
✅ สไลด์มีครบ (ฉบับ Post คือบรรทัด 24–26) · ระวังตอนลอกไปแค่บางส่วน |
ฉบับ Post ลบ KC_HOSTNAME: localhost ทิ้ง |
✅ แก้ถูกจุด · ทำให้เรียก Keycloak ด้วยชื่อ service ใน compose แล้ว oidc.NewProvider() ไม่ error — พิสูจน์ในหัวข้อ 5 |
KC_HOSTNAME_STRICT: "false" ที่ยังเหลืออยู่ |
⚠️ ผลเท่ากับไม่ใส่ในโหมด start-dev · แต่ถ้าเผลอเปลี่ยนเป็น true คอนเทนเนอร์ตายทันที |
สไลด์ไม่ได้เตือนว่า issuer วิ่งตาม Host: header |
⚠️ ต้องเติม: บน production ต้องปักหมุด hostname ให้ถูก ไม่ใช่ปล่อยลอย |
--import-realm |
❌ ไม่ทำงาน เพราะไม่มีไฟล์ใน /opt/keycloak/data/import · และไม่ error ด้วย |
| สไลด์ไม่ได้บอกว่านี่คือค่าสำหรับ dev เท่านั้น | ⚠️ ต้องเติม: start-dev + KC_HTTP_ENABLED=true ห้ามขึ้น production |
สไลด์ไม่ได้พูดถึง aud |
⚠️ ต้องรู้ไว้: aud = "account" · ตัวที่บอก client คือ azp |
ทดสอบความเข้าใจ
1. ขอ access_token จาก Keycloak โดยระบุ client_id=wongnok-api แล้วถอด payload ออกมาดู — claim aud เป็นอะไร?
payload จริงที่ถอดมาได้มีทั้ง "aud": "account" และ "azp": "wongnok-api" อยู่พร้อมกัน
ตัวเลือกที่ 1 เป็นความเชื่อที่พบบ่อยที่สุดและเป็นตัวที่ทำให้คนเสียเวลามากที่สุด —
ถ้าเขียน middleware ตรวจ aud == "wongnok-api" จะได้ 401 ทุกใบ
ทั้งที่ token ถูกต้องสมบูรณ์ แล้วคุณจะไปไล่หาที่ผิดใน Keycloak โดยที่ไม่มีอะไรผิดเลย
ตัวเลือกที่ 4 ผิดชัด เพราะ aud อยู่ใน payload จริง
จุดที่ต้องจำ: azp (Authorized Party) คือช่องที่ตอบว่า
"token ใบนี้ออกให้ client ตัวไหน" · ส่วน aud ตอบว่า "ตั้งใจให้ใครเอาไปใช้"
ซึ่ง Keycloak ตั้ง default ไว้เป็น account
2. สไลด์ฉบับ Post ลบบรรทัด KC_HOSTNAME: localhost ออกจาก compose — ผลที่วัดได้จริงคืออะไร?
ผมยกสองคอนเทนเนอร์ที่ต่างกันบรรทัดเดียวขึ้นมาเทียบ แล้วยิงด้วย Host: evil.example.com:
· ตัวที่มี KC_HOSTNAME ตอบ http://localhost/realms/master — ไม่เชื่อที่ขอมา
· ตัวที่ไม่มี ตอบ http://evil.example.com/realms/master — เชื่อทุกอย่าง
ตัวเลือกที่ 1 ผิด — start-dev รันขึ้นปกติโดยไม่ต้องมี KC_HOSTNAME
(ตัวที่ไม่ยอม start คือตอนตั้ง KC_HOSTNAME_STRICT=true ควบคู่กับการไม่มี hostname)
ตัวเลือกที่ 3 ตรงข้ามกับผลจริง — พอร์ตยังติดมา ทั้งสองแบบ เพราะ Keycloak อ่านจาก request
ตัวเลือกที่ 4 คือคำตอบที่ดูปลอดภัยที่สุดและผิดที่สุด
จุดที่ต้องจำ: ตอนเปิดจากเบราว์เซอร์ที่ localhost ทั้งสองแบบเหมือนกันเป๊ะ —
ความต่างจะโผล่ตอนที่ Go API ย้ายเข้าไปอยู่ใน compose แล้วเรียก http://keycloak:8080
ตรงนั้น oidc.NewProvider() จะฟ้อง
issuer URL provided to client … did not match the issuer URL returned by provider
ตั้งแต่โปรแกรมยังไม่ทันล็อกอิน
3. ดึง JWKS จาก jwks_uri ของ realm มาแล้วจะเอาไป verify ลายเซ็น — ควรหยิบกุญแจใบไหน?
ผลรันจริง: number of keys = 2 — ใบหนึ่ง use: "sig" / alg: "RS256"
(สำหรับตรวจลายเซ็น) อีกใบ use: "enc" / alg: "RSA-OAEP" (สำหรับเข้ารหัส)
คนละหน้าที่กันสิ้นเชิง
ตัวเลือกที่ 1 คือกับดักตัวจริง เพราะมันใช้ได้บ้างไม่ได้บ้าง —
ลำดับในอาร์เรย์ไม่มีอะไรรับประกัน และเปลี่ยนได้เมื่อ Keycloak หมุนกุญแจ
บั๊กแบบนี้จะโผล่ตอนที่คุณไม่ได้แตะโค้ดอะไรเลย
ตัวเลือกที่ 2 ผิดเพราะสองใบนี้เป็นคนละคู่กุญแจ ไม่ใช่รูปแบบต่างของอันเดียวกัน
ตัวเลือกที่ 3 ผิด — ผมพิสูจน์แล้วว่า verify ได้ด้วย JWKS ล้วน ๆ ไม่ต้องยิงถาม Keycloak เลย
จุดที่ต้องจำ: kid ใน header ของ JWT มีไว้เพื่อการนี้โดยเฉพาะ —
ผู้เซ็นบอกมาแล้วว่าใช้ใบไหน อย่าเดาเอง
4. service ตัวที่สองในระบบถือ public key จาก jwks_uri ไว้ — มันตรวจ token ใบไหนได้บ้าง?
ถอด header ของ token สองใบจากคำขอเดียวกันมาเทียบได้ชัดเจน:
access_token ใช้ "alg": "RS256" ส่วน refresh_token ใช้
"alg": "HS512"
RS256 เป็น asymmetric — เซ็นด้วย private key ตรวจด้วย public key จึงแจก public key ได้
HS512 เป็น symmetric — เซ็นกับตรวจใช้ความลับตัวเดียวกัน ซึ่งอยู่ที่ Keycloak เท่านั้น
ไม่มีการแจกออกมาใน JWKS
ตัวเลือกที่ 3 ผิด และผมพิสูจน์ตรงข้ามด้วยโปรแกรม Go ที่ขึ้นว่า
VERIFY OK -> signature valid using ONLY the public key from jwks_uri (no client_secret used)
· แถมแก้ token แล้วยิงซ้ำก็ถูกปฏิเสธด้วย crypto/rsa: verification error
จุดที่ต้องจำ: refresh_token ไม่ได้ออกแบบมาให้ service อื่นอ่านหรือตรวจ —
หน้าที่เดียวของมันคือถูกส่งกลับไปที่ token_endpoint เพื่อแลกใบใหม่
เป้าหมายไม่ใช่ "ทำให้ Keycloak ขึ้น" — เป้าหมายคือ ทำให้คุณตรวจสอบข้ออ้างในสไลด์ได้เองโดยไม่ต้องเชื่อใคร
- ขึ้นให้ได้ก่อน: สร้างโฟลเดอร์เปล่า ใส่
compose.ymlที่มีแค่ servicekeycloakจากหัวข้อ 3 พร้อมบล็อกvolumes:ล่างสุด แล้วสั่งdocker-compose up -d
รอจนcurl -s -o /dev/null -w '%{http_code}' http://localhost:8081/realms/master/.well-known/openid-configurationตอบ 200 - อ่านล็อกให้ครบสามบรรทัด:
docker logs wongnok-keycloak | tail
จดเวอร์ชันจริงที่ได้ — ของผมคือ 26.7.2 · ของคุณอาจต่าง เพราะ26.7เป็น floating tag - ยืนยัน no-op ด้วยตาตัวเอง:
docker logs wongnok-keycloak | head -20แล้วหาบรรทัดHostnameV2ProviderFactory
จากนั้นลบบรรทัดKC_HOSTNAME_STRICTทิ้ง แล้วdocker-compose up -d --force-recreate— ยืนยันว่ายังใช้งานได้เหมือนเดิมทุกอย่าง - จับ
--import-realmให้ได้คาหนังคาเขา:docker logs wongnok-keycloak 2>&1 | grep -i import
ต้องไม่เจอบรรทัดที่บอกว่ามัน import อะไร — ถ้าเจอ แปลว่าคุณ mount ไฟล์ realm เข้าไปแล้วโดยไม่รู้ตัว - อ่าน discovery ทั้งไฟล์:
curl -s http://localhost:8081/realms/master/.well-known/openid-configuration
หาtoken_endpointกับjwks_uriให้เจอ แล้ว ตอบให้ได้ว่าissuerที่ได้มี:8081ติดมาหรือไม่ และทำไม - สร้างของครบชุดผ่าน UI: เข้า
http://localhost:8081ด้วยadmin/adminแล้วสร้าง realmwongnok→ clientwongnok-api(เปิด Direct access grants) → userchef1(ตั้ง password) → realm rolerecipe:write→ groupchefs - ทำท่าสำคัญ: แปะ role
recipe:writeเข้ากับ groupchefsแล้วเอาchef1ใส่ group นั้น
ห้ามแปะ role เข้ากับchef1โดยตรงเด็ดขาด — ทั้งบทนี้อยู่ที่ข้อนี้ - ขอ token แล้วเปิดดู: ยิง
POSTไปที่token_endpointด้วยgrant_type=password· เอาaccess_tokenที่ได้ไปแปะที่ jwt.io หรือถอดเอง
ตอบสามคำถาม:audเป็นอะไร ·azpเป็นอะไร ·recipe:writeอยู่ที่ claim ไหน - ท้าทาย:
curlไปที่jwks_uriแล้วนับจำนวนกุญแจ · หาkidจาก header ของ token ที่คุณเพิ่งได้ · แล้วชี้ว่าใบไหนใน JWKS คือใบที่ตรงกัน
จากนั้นตอบว่า ถ้าโค้ดเขียนKeys[0]วันนี้ผ่านไหม แล้วพรุ่งนี้ล่ะ
· aud = "account" ไม่ใช่ wongnok-api
· azp = "wongnok-api" — นี่คือช่องที่บอกว่า token ออกให้ client ไหน
· recipe:write อยู่ที่ realm_access.roles
ไม่ใช่ resource_access
"azp": "wongnok-api",
"preferred_username": "chef1",
"realm_access": { "roles": ["default-roles-wongnok", "recipe:write",
"offline_access", "uma_authorization"] }
ถ้าอยากยืนยันซ้ำว่ามาจาก group จริง ไม่ใช่เผลอไปแปะที่ user
ให้ถาม Keycloak สองคำถามเทียบกัน — ผลของผมคือ
role โดยตรง ของ chef1: ['default-roles-wongnok']
role ที่มีผลจริง (composite): ['default-roles-wongnok', 'offline_access', 'recipe:write', 'uma_authorization']
ถ้าคำถามแรกของคุณมี recipe:write โผล่มาด้วย แปลว่าคุณเผลอแปะเข้า user
ตรง ๆ — ให้ถอดออกแล้วทำข้อ 7 ใหม่ ไม่งั้นข้อนี้ไม่ได้พิสูจน์อะไรเลย
💬 ข้อ 7 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ —
เพราะ "ผูก role ที่ group แทนที่จะผูกที่คน" คือความต่างระหว่างระบบสิทธิ์ที่ดูแลไหว
กับระบบที่พอมีพนักงาน 200 คนแล้วไม่มีใครกล้าแตะ
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย —
ทุกบรรทัดในหน้านี้วัดบน Keycloak 26.7.2 (มาจาก tag 26.7)
กับ Go 1.26.5 darwin/arm64
ที่ต่างได้บ่อยที่สุดคือเวอร์ชัน เพราะ 26.7 เป็น floating tag —
ถ้าคุณ pull ทีหลัง อาจได้ 26.7.3 หรือใหม่กว่า แล้วค่า default บางตัว (เช่นอายุ token)
อาจไม่ตรงกับที่ผมจดไว้
อีกอย่างที่ควรถามตัวเองก่อนส่งมา: คุณยิง curl ไปที่พอร์ต 8081 หรือ 8080
อ้างอิง
- Keycloak — Getting started with Docker — อ่านย่อหน้าที่อธิบาย
start-devซึ่งบอกตรง ๆ ว่าโหมดนี้มีไว้ทำอะไรและห้ามใช้ที่ไหน - Keycloak — Server configuration (ทุกตัวแปร) — ที่มาของ
KC_HOSTNAME,KC_HOSTNAME_STRICT,KC_HTTP_ENABLED· ค้นคำว่า hostname-strict แล้วอ่านหมายเหตุ จะเจอเหตุผลของบรรทัดที่เป็น no-op - Keycloak — Importing and exporting realms — อ่านว่า
--import-realmอ่านไฟล์จากโฟลเดอร์ไหน (/opt/keycloak/data/import) ซึ่งอธิบายว่าทำไม flag ในสไลด์ถึงเงียบ - OpenID Connect Discovery 1.0 — ต้นทางของ
/.well-known/openid-configurationและรายชื่อ field ทั้งหมดที่เห็นในหัวข้อ 5 - RFC 7519 — JWT Registered Claim Names — นิยามทางการของ
iss,aud,exp,sub· สังเกตว่าazpไม่ได้อยู่ในนี้ เพราะมันมาจากฝั่ง OIDC ไม่ใช่ JWT พื้นฐาน - RFC 7517 — JSON Web Key (JWK) — ที่มาของ field
kid,use,alg,n,eที่เห็นในหัวข้อ 7 - quay.io — รายการ tag ทั้งหมดของ Keycloak — ใช้เช็คว่า tag ที่จะเขียนใน compose มีจริงไหม และตอนนี้ชี้ไปเวอร์ชันไหน
- บทเรียน 44 — Configuration & Environment Variables — ค่าใน
environment:ของ compose ก็คือ env ตัวเดียวกับที่บทนั้นสอน