บทเรียน 51 · Day 9 · Authentication

Keycloak — Authorization Server ที่รันเอง

~20 นาที · ต้องผ่าน: บทเรียน 50 (OAuth2 / OIDC — ใครคือ Authorization Server) และเคยใช้ docker compose มาแล้ว (บทเรียน 30)

บทที่แล้วจบที่ภาพวงจร OAuth2 — มีกล่องชื่อ "Authorization Server" ยืนอยู่กลางแผนภาพ แล้วออก token ให้แอปของเรา
บทนี้คือการเอากล่องนั้นมารันจริงบนเครื่องตัวเอง ด้วย Keycloak จากสไลด์ Day 9 หน้า 38–42
ผมรันของจริงตามสไลด์ทุกบรรทัด แล้วเจอสามอย่างที่ต้องบอกคุณก่อนที่คุณจะไปเสียเวลากับมัน: มีบรรทัดหนึ่งใน compose ที่ไม่มีผลอะไรเลย · มี flag หนึ่งที่ไม่ได้ทำงานแต่ก็ไม่ error · และ claim ที่ทุกคนคิดว่าจะมี กลับไม่มี
ส่วนข้ออ้างใหญ่ที่สุดในสไลด์ — "ทุก service ใช้ public key เดียวกัน verify ได้ ไม่ต้องแชร์ secret" — ผมเขียนโปรแกรม Go ตรวจลายเซ็นจริงมาให้ดูว่ามันจริง แต่จริงแค่ครึ่งเดียว

🧰 ของที่ใช้ในบทนี้ — ทุก 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

คำใหม่ในตารางนี้มีสองคำ นิยามไว้ก่อน:

📌 แถวสุดท้ายคือแถวเดียวที่พิสูจน์ได้ด้วยการรัน — และเราจะพิสูจน์มันจริงในหัวข้อ 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_USERNAME
KC_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 อย่าอ่านสลับกัน

⚠️ สองบรรทัดนี้ห้ามหลุดขึ้น production เด็ดขาด

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

✅ จุดที่สไลด์เคยผิดข้อที่ 1 — ฉบับ Post แก้แล้ว แต่แก้คนละบรรทัดกับที่ผมเสนอ

สไลด์ฉบับก่อนเรียนตั้งสองบรรทัดนี้คู่กัน แล้ว 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

⚠️ จุดที่สไลด์ผิดข้อที่ 2 — --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_endpointbackend ของเราเอา code ที่ได้กลับมา แลกเป็น access_token — "ขั้นที่ 5"
jwks_uriทุก service ที่ต้อง verifyดึง public key มาตรวจลายเซ็น token — พระเอกของหัวข้อ 7
userinfo_endpointbackendถามข้อมูลผู้ใช้เพิ่มโดยยื่น access_token ให้ดู
introspection_endpointbackendถาม Keycloak ตรง ๆ ว่า "token ใบนี้ยังใช้ได้อยู่ไหม"
end_session_endpointbackend / เบราว์เซอร์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: localhostkcpost — ไม่มี
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 ใน token ที่คนสับสนกันมากที่สุด

สาม 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 ไม่ได้ (มันตรวจได้อย่างเดียว เซ็นไม่ได้)

⚠️ ข้อสรุปที่คนเหมาเอาเอง แล้วผิด — "token ทุกใบ verify ด้วย public key ได้"

ผมถอด 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 เพื่อแลกใบใหม่

🛠️ ภารกิจ — รัน Authorization Server ของตัวเอง แล้วพิสูจน์ทุกข้ออ้างด้วยมือ

เป้าหมายไม่ใช่ "ทำให้ Keycloak ขึ้น" — เป้าหมายคือ ทำให้คุณตรวจสอบข้ออ้างในสไลด์ได้เองโดยไม่ต้องเชื่อใคร

  1. ขึ้นให้ได้ก่อน: สร้างโฟลเดอร์เปล่า ใส่ compose.yml ที่มีแค่ service keycloak จากหัวข้อ 3 พร้อมบล็อก volumes: ล่างสุด แล้วสั่ง docker-compose up -d
    รอจน curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/realms/master/.well-known/openid-configuration ตอบ 200
  2. อ่านล็อกให้ครบสามบรรทัด: docker logs wongnok-keycloak | tail
    จดเวอร์ชันจริงที่ได้ — ของผมคือ 26.7.2 · ของคุณอาจต่าง เพราะ 26.7 เป็น floating tag
  3. ยืนยัน no-op ด้วยตาตัวเอง: docker logs wongnok-keycloak | head -20 แล้วหาบรรทัด HostnameV2ProviderFactory
    จากนั้นลบบรรทัด KC_HOSTNAME_STRICT ทิ้ง แล้ว docker-compose up -d --force-recreate — ยืนยันว่ายังใช้งานได้เหมือนเดิมทุกอย่าง
  4. จับ --import-realm ให้ได้คาหนังคาเขา: docker logs wongnok-keycloak 2>&1 | grep -i import
    ต้องไม่เจอบรรทัดที่บอกว่ามัน import อะไร — ถ้าเจอ แปลว่าคุณ mount ไฟล์ realm เข้าไปแล้วโดยไม่รู้ตัว
  5. อ่าน discovery ทั้งไฟล์: curl -s http://localhost:8081/realms/master/.well-known/openid-configuration
    หา token_endpoint กับ jwks_uri ให้เจอ แล้ว ตอบให้ได้ว่า issuer ที่ได้มี :8081 ติดมาหรือไม่ และทำไม
  6. สร้างของครบชุดผ่าน UI: เข้า http://localhost:8081 ด้วย admin/admin แล้วสร้าง realm wongnok → client wongnok-api (เปิด Direct access grants) → user chef1 (ตั้ง password) → realm role recipe:write → group chefs
  7. ทำท่าสำคัญ: แปะ role recipe:write เข้ากับ group chefs แล้วเอา chef1 ใส่ group นั้น
    ห้ามแปะ role เข้ากับ chef1 โดยตรงเด็ดขาด — ทั้งบทนี้อยู่ที่ข้อนี้
  8. ขอ token แล้วเปิดดู: ยิง POST ไปที่ token_endpoint ด้วย grant_type=password · เอา access_token ที่ได้ไปแปะที่ jwt.io หรือถอดเอง
    ตอบสามคำถาม: aud เป็นอะไร · azp เป็นอะไร · recipe:write อยู่ที่ claim ไหน
  9. ท้าทาย: 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

อ้างอิง