บทเรียน 52 · Day 9 · OAuth2 ของจริง

flow จริงของ Wongnok: /login /callback /exchange /logout

~25 นาที · ต้องมี: บทเรียน 50 (Authorization Code flow) · บทเรียน 32 (Redis กับ TTL) · บทเรียน 35 (query / body / header)

บทนี้มาจากสไลด์ Day 9 หน้า 44–47 ซึ่งเป็น sequence diagram รูปเดียวกันสี่หน้า แค่ไฮไลต์คนละช่วง
สิ่งที่บทนี้ตอบให้ขาดคือคำถามเดียว ที่มือใหม่ทุกคนถามตอนเห็น diagram นี้ครั้งแรก: ทำไมต้องมีขั้น /exchange เพิ่มมาอีกขั้น ในเมื่อตอน redirect กลับมาก็แนบ access_token มาใน URL ให้จบเลยก็ได้?
คำตอบไม่ใช่ "เพราะมันปลอดภัยกว่า" ลอย ๆ — ผมรันจริงให้ดูว่า อะไรที่อยู่ใน URL มันไปโผล่ที่ไหนบ้าง แล้วเทียบกับของที่อยู่ใน body ของ POST
ทุก output ในหน้านี้มาจาก Redis จริง · Keycloak จริง · เบราว์เซอร์จริง ไม่มีตัวไหนพิมพ์เอง

🧰 ของที่ใช้ในบทนี้ (เวอร์ชันที่รันจริง)

go version go1.26.5 darwin/arm64
github.com/redis/go-redis/v9 v9.22.0

redis:8-alpine                    # redis_version:8.10.1  os:Linux 6.8.0-117-generic aarch64
quay.io/keycloak/keycloak:26.7    # 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)
Google Chrome 151.0.7922.170      # ใช้ตอนวัดเรื่อง Referer ในหัวข้อ 5

คำเตือนเรื่องพอร์ต: ในหน้านี้ผมรัน Keycloak ไว้ที่ localhost:18081 และ Redis ที่ 127.0.0.1:16379 เพราะเครื่องผมมีของอื่นจองพอร์ตปกติไว้อยู่ ถ้าคุณรันตามสไลด์จะเป็น 8081 กับ 6379 — ตัวเลขพอร์ตในผลรันจึงต่างจากของคุณได้ แต่พฤติกรรมเหมือนกันหมด

1 · ตัวละครห้าตัวใน diagram นี้

ก่อนดูเส้น ต้องรู้ก่อนว่าใครเป็นใคร เพราะ diagram นี้มีจุดที่คนอ่านสลับกันบ่อยมาก คือ Browser กับ Frontend JS ซึ่งอยู่ในหน้าจอเดียวกันแต่ไม่ใช่ตัวเดียวกัน:

ตัวละครคืออะไรในบทนี้ทำหน้าที่อะไร
Browser ตัวโปรแกรมเบราว์เซอร์เอง — คนที่เดินตาม redirect เปลี่ยน URL บนแถบที่อยู่ และเก็บ history เป็นคนวิ่งไปมาระหว่าง wongnok API กับ Keycloak ในขั้น 1–2 โดย JavaScript ไม่ได้สั่ง
Frontend JS โค้ด JavaScript ในหน้าเว็บ (React/Vue) ที่เรียก API ด้วย axios / fetch โผล่มาตอนขั้น 3–4 เท่านั้น — ขั้น 1–2 มันยังไม่ได้ทำงานเลยด้วยซ้ำ
wongnok API backend Go ของเรา (Gin) ที่ถือ client_secret ไว้ เป็นตัวกลางระหว่างผู้ใช้กับ Keycloak · เป็นที่เดียวที่รู้ client_secret
Redis ที่เก็บ key-value ในหน่วยความจำ ที่ตั้งวันหมดอายุ (TTL) ให้แต่ละ key ได้ (บทเรียน 32) เก็บของสองอย่างที่อายุสั้นมาก: oauth_state:xxx กับ auth_ticket:yyy
Keycloak Identity Provider — เจ้าของหน้า login และเจ้าของ token ตรวจรหัสผ่าน · ออก access_token / refresh_token · ปิด session ตอน logout
⚠️ จุดที่คนอ่าน diagram นี้พลาดมากที่สุด

เส้นในขั้น 1 กับ 2 ออกจาก Browser ไม่ใช่ออกจาก Frontend JS
แปลว่าตอนนั้น axios ไม่ได้ยิงอะไรเลย — เป็นเบราว์เซอร์ที่เปลี่ยนหน้าไปเรื่อย ๆ ตาม Location: ที่แต่ละฝั่งตอบกลับมา
พอมาถึงขั้น 3 เส้นถึงย้ายมาออกจาก Frontend JS — และนั่นคือจุดที่โค้ด JavaScript ของเราเพิ่งได้ทำงานครั้งแรกของทั้ง flow

2 · ภาพรวมทั้งเส้น

นี่คือ diagram เดียวกับสไลด์หน้า 44–47 ทั้งสี่หน้ารวมกัน วาดใหม่เป็นตัวอักษรให้อ่านทีละบรรทัดได้
ลูกศร ─▶ คือ request ที่วิ่งออกไป · ◀─ คือ response ที่ตอบกลับมา

  Browser        Frontend JS        wongnok API         Redis         Keycloak
     │                │                  │                │               │
     │                │                  │                │               │
 ═══ STEP 1  /login      window.location  (NOT ajax) ════════════════════════════════════════
     │                │                  │                │               │
     │  GET /api/v1/auth/login           │                │               │
     │───────────────────────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  SET oauth_state:xxx  EX 300   │
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │  302  Location: KEYCLOAK/auth?...&state=xxx        │               │
     ◀───────────────────────────────────│                │               │
     │                │                  │                │               │
     │  GET /auth?client_id&redirect_uri&state=xxx        │               │
     │────────────────────────────────────────────────────────────────────▶
     │                │                  │                │               │
     │  200  Keycloak login page         │                │               │
     ◀────────────────────────────────────────────────────────────────────│
     │                │                  │                │               │
     │  POST username + password         │                │               │
     │────────────────────────────────────────────────────────────────────▶
     │                │                  │                │               │
     │                │                  │                │               │
 ═══ STEP 2  /callback   browser is still following redirects ═══════════════════════════════
     │                │                  │                │               │
     │  302  Location: /api/v1/auth/callback?code&state   │               │
     ◀────────────────────────────────────────────────────────────────────│
     │                │                  │                │               │
     │  GET /api/v1/auth/callback?code=...&state=xxx      │               │
     │───────────────────────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  GETDEL oauth_state:xxx   (check + delete)
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │                │                  │  POST /token   code + client_secret
     │                │                  │────────────────────────────────▶
     │                │                  │                │               │
     │                │                  │  200 {access_token, refresh_token, ...}
     │                │                  ◀────────────────────────────────│
     │                │                  │                │               │
     │                │                  │  SET auth_ticket:yyy  EX 30  = credential
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │  302  Location: FRONTEND_URL/auth/success?ticket=yyy               │
     ◀───────────────────────────────────│                │               │
     │                │                  │                │               │
     │                │                  │                │               │
 ═══ STEP 3  /exchange   axios  (ajax) ══════════════════════════════════════════════════════
     │                │                  │                │               │
     │  page loads -> JS reads ticket from URL            │               │
     │────────────────▶                  │                │               │
     │                │                  │                │               │
     │                │  POST /api/v1/auth/exchange   body {ticket: yyy}  │
     │                │──────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  GETDEL auth_ticket:yyy        │
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │                │  200 {accessToken, refreshToken, bearerType, expiresAt}
     │                ◀──────────────────│                │               │
     │                │                  │                │               │
     │                │                  │                │               │
 ═══ STEP 4  /logout     axios  (ajax) ══════════════════════════════════════════════════════
     │                │                  │                │               │
     │                │  POST /api/v1/auth/logout   body {refreshToken}   │
     │                │──────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  POST /logout  refresh_token + client_secret
     │                │                  │────────────────────────────────▶
     │                │                  │                │               │
     │                │                  │  204 No Content                │
     │                │                  ◀────────────────────────────────│
     │                │                  │                │               │
     │                │  204 No Content  │                │               │
     │                ◀──────────────────│                │               │
     │                │                  │                │               │
  Browser        Frontend JS        wongnok API         Redis         Keycloak

🧭 วิธีอ่าน diagram นี้ให้เร็ว — ดูที่เส้นแนวตั้งซ้ายสุด (Browser) ก่อน
ตราบใดที่ยังมีลูกศรออกจากเส้นนั้น แปลว่ายังอยู่ในโลกของ redirect — ผู้ใช้ยังเห็นหน้าจอกระพริบ URL เปลี่ยนไปเรื่อย ๆ
พอลูกศรย้ายไปออกจากเส้น Frontend JS แปลว่าเข้าโลกของ AJAX แล้ว — URL บนแถบที่อยู่หยุดนิ่ง ทุกอย่างเกิดเบื้องหลัง

🏢 เทียบให้เห็นภาพ — เดินไปอีกตึกเอง กับ ให้เจ้าหน้าที่วิ่งไปให้

ไปติดต่อราชการ มีสองแบบที่ต่างกันคนละเรื่อง:
· แบบเดินไปเอง — เจ้าหน้าที่บอกว่า “เรื่องนี้ต้องไปเซ็นที่ตึก B” คุณต้องลุกเดินไปยืนที่ตึก B จริง ๆ เห็นป้ายชื่อตึกด้วยตาตัวเอง แล้วเซ็นตรงนั้น
· แบบนั่งรอ — คุณยื่นเรื่องแล้วนั่งอยู่ที่เดิม เจ้าหน้าที่เดินไปคุยกับตึก B ให้ คุณไม่ได้ขยับไปไหนเลย

ขั้น 1–2 (/login · /callback) คือแบบเดินไปเอง — เบราว์เซอร์ย้ายทั้งหน้าไปที่ Keycloak จริง URL บนแถบที่อยู่เปลี่ยน เพราะผู้ใช้กำลังจะพิมพ์รหัสผ่าน เขาต้องเห็นชื่อโดเมนของเจ้าของหน้า login ด้วยตาตัวเอง
ขั้น 3–4 (/exchange · /logout) คือแบบนั่งรอ — axios วิ่งไปคุยให้ ผู้ใช้ยังอยู่หน้าเดิม

ที่ไม่เหมือนกัน: ที่ราชการคุณเลือกได้ว่าจะเดินไปหรือไม่เดิน แต่บนเว็บไม่มีใครถามคุณ — พอเซิร์ฟเวอร์ตอบ 302 พร้อม Location: เบราว์เซอร์ก็เดินตามให้ทันทีโดยที่ผู้ใช้ไม่ได้กดอะไรเลย
และในขั้น 1–2 โค้ด JavaScript ของเรายังไม่ได้ทำงานแม้แต่บรรทัดเดียว — มันเพิ่งได้ทำงานครั้งแรกตอนขั้น 3

🗣 ศัพท์ที่เพิ่งเรียนไป
redirect
การที่เซิร์ฟเวอร์ตอบว่า “ไม่ใช่ที่นี่ ไปที่นี่แทน” แล้วเบราว์เซอร์ย้ายหน้าตามให้เอง · พูดกับ senior ว่า “endpoint นี้ตอบ 302 redirect ใช่ไหมครับ ผมเลยเรียกด้วย axios ไม่ได้”
Location header
บรรทัดใน response ที่บอกว่าให้ไปต่อที่ URL ไหน · เป็นตัวที่ทำให้ redirect เกิดขึ้นจริง · ถาม AI ว่า “ช่วยดู response header ให้หน่อย ทำไม Location ชี้ผิด path”
AJAX
การที่ JavaScript ยิง request เองเบื้องหลังโดยหน้าจอไม่เปลี่ยน (axios / fetch ทำงานแบบนี้) · ประโยคที่ใช้ได้จริงตอนอ่าน diagram: “ขั้นนี้เป็น AJAX หรือเป็น full page redirect ครับ”
user agent
ศัพท์ทางการที่มาตรฐานใช้เรียก “ตัวเบราว์เซอร์” · เจอคำนี้ในเอกสารเมื่อไหร่ ให้อ่านว่า เบราว์เซอร์ของผู้ใช้ — เดี๋ยวเจออีกทีตอนท้ายบท ตอนที่ RFC 6749 พูดถึง state

3 · ขั้นที่ 1 — /login (สไลด์หน้า 44)

  Browser        Frontend JS        wongnok API         Redis         Keycloak
     │                │                  │                │               │
     │  GET /api/v1/auth/login        <- window.location, NOT ajax        │
     │───────────────────────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  SET oauth_state:xxx  EX 300   │
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │  302  Location: KEYCLOAK/auth?...&state=xxx        │               │
     ◀───────────────────────────────────│                │               │
     │                │                  │                │               │
     │  GET /auth?client_id&redirect_uri&state=xxx        │               │
     │────────────────────────────────────────────────────────────────────▶
     │                │                  │                │               │
     │  200  Keycloak login page         │                │               │
     ◀────────────────────────────────────────────────────────────────────│
     │                │                  │                │               │
     │  POST username + password         │                │               │
     │────────────────────────────────────────────────────────────────────▶
     │                │                  │                │               │
  Browser        Frontend JS        wongnok API         Redis         Keycloak

ต้องใช้ window.location เท่านั้น ห้ามใช้ AJAX

จุดนี้สไลด์เขียนไว้ตัวเล็ก ๆ ว่า "window.location เท่านั้น" แต่มันเป็นเรื่องคอขาดบาดตาย
ก่อนอื่นนิยามสองคำ:

คำหมายความว่า
window.location = "..." สั่งเบราว์เซอร์ให้ย้ายทั้งหน้าไปที่ URL นั้น — หน้าปัจจุบันถูกทิ้ง แถบที่อยู่เปลี่ยน และ redirect ต่อ ๆ ไปเบราว์เซอร์เดินตามให้เอง
AJAX (axios.get(...)) ยิง request เบื้องหลังโดยไม่เปลี่ยนหน้า — JavaScript ได้ response กลับมาเป็นข้อมูลก้อนหนึ่ง หน้าจอไม่ขยับ

ทีนี้ดูว่า /api/v1/auth/login ตอบอะไรกลับมา: มันตอบ 302 พร้อม header Location: ที่ชี้ไปหน้า login ของ Keycloak
ถ้าใช้ AJAX จะพังสองชั้นซ้อน:

Keycloak ตอบหน้า login เป็น HTML จริง ๆ — ผม curl ไปที่ authorization_endpoint แล้วนับขนาดที่ได้:

$ curl -s -c cookies.txt "$KC/realms/wongnok/protocol/openid-connect/auth?client_id=wongnok-api&response_type=code&scope=openid&redirect_uri=$RU&state=$STATE" | wc -c
6882          # หน้า HTML ล้วน ๆ ไม่ใช่ JSON — เอาไปให้ axios ก็ทำอะไรต่อไม่ได้

state เกิดตรงนี้ แล้วถูกฝากไว้ที่ Redis

ก่อนจะ 302 ออกไป wongnok API สุ่มค่าขึ้นมาหนึ่งค่า เรียกว่า state แล้วทำสองอย่างพร้อมกัน: เก็บลง Redis และ แนบไปกับ URL ของ Keycloak
ผมรัน Redis จริงให้ดูว่าคำสั่งนั้นเกิดอะไรขึ้น (EX 300 คือ 300 วินาที = 5 นาที ตามสไลด์):

SET oauth_state:9f2b7c1e "1" EX 300 -> OK
TTL oauth_state:9f2b7c1e            -> 300
EXISTS oauth_state:9f2b7c1e         -> 1

เหตุผลที่ต้องเก็บฝั่งเรา: เดี๋ยวตอนขั้น 2 Keycloak จะส่ง state ค่านี้กลับมาให้เรา และเราต้องพิสูจน์ให้ได้ว่ามันเป็นค่าที่เราเป็นคนออกให้เอง ไม่ใช่ค่าที่ใครก็ไม่รู้แต่งมา — รายละเอียดว่ามันกันอะไรได้ อยู่ในหัวข้อ 9

4 · ขั้นที่ 2 — /callback (สไลด์หน้า 45)

  Browser        Frontend JS        wongnok API         Redis         Keycloak
     │                │                  │                │               │
     │  302  Location: /api/v1/auth/callback?code&state   │               │
     ◀────────────────────────────────────────────────────────────────────│
     │                │                  │                │               │
     │  GET /api/v1/auth/callback?code=...&state=xxx      │               │
     │───────────────────────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  GETDEL oauth_state:xxx   (check + delete)
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │                │                  │  POST /token   code + client_secret
     │                │                  │────────────────────────────────▶
     │                │                  │                │               │
     │                │                  │  200 {access_token, refresh_token, ...}
     │                │                  ◀────────────────────────────────│
     │                │                  │                │               │
     │                │                  │  SET auth_ticket:yyy  EX 30  = credential
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │  302  Location: FRONTEND_URL/auth/success?ticket=yyy               │
     ◀───────────────────────────────────│                │               │
     │                │                  │                │               │
  Browser        Frontend JS        wongnok API         Redis         Keycloak

ขั้นนี้เริ่มตอนผู้ใช้กด "เข้าสู่ระบบ" ที่หน้า Keycloak แล้ว Keycloak ตอบ 302 เด้งเบราว์เซอร์กลับมาที่ redirect_uri ที่เราลงทะเบียนไว้
ผมรันของจริง: สร้าง realm wongnok + client wongnok-api แบบ confidential (มี client_secret) + user chef1 แล้วเดินฟอร์ม login ด้วย curl นี่คือ header ที่ Keycloak ตอบกลับมาจริง ๆ:

HTTP/1.1 302 Found
Location: http://localhost:8080/api/v1/auth/callback?state=9f2b7c1e-state-from-redis&session_state=afOr4xGE8XiarnPyxNrzqiyn&iss=http%3A%2F%2Flocalhost%3A18081%2Frealms%2Fwongnok&code=10f0f184-2410-7b36-ab64-b85d03545adf.afOr4xGE8XiarnPyxNrzqiyn.5a65a5e5-7360-4b21-ae33-8b765128d89f

แกะ query string ออกมาทีละตัว:

path  = /api/v1/auth/callback
state           = 9f2b7c1e-state-from-redis  (ยาว 25)
session_state   = afOr4xGE8XiarnPyxNrzqiyn  (ยาว 24)
iss             = http://localhost:18081/realms/wongnok  (ยาว 37)
code            = 10f0f184-2410-7b36-ab64-b85d03545adf.afOr4xGE8XiarnPyxNrzqiy...  (ยาว 98)

สังเกต state ที่กลับมา: 9f2b7c1e-state-from-redis — เป็นค่าเดียวกับที่เราส่งไปเป๊ะ ๆ
Keycloak ไม่ได้ทำอะไรกับมันเลย มันแค่คืนกลับมาให้เหมือนเดิม ซึ่งเป็นทั้งหมดที่ระบบ state ต้องการ

เช็ค state แล้วลบทิ้งในคำสั่งเดียว

สไลด์เขียนว่า "เช็ค + ลบ oauth_state:xxx" — คำสั่ง Redis ที่ทำสองอย่างนี้พร้อมกันชื่อ GETDEL (อ่านค่าคืนมา แล้วลบ key นั้นทิ้ง เป็นคำสั่งเดียวกัน)
ผมรันจริง รวมถึงเคสที่คนพยายามใช้ state เดิมซ้ำ และเคสที่แต่ง state ขึ้นมาเอง:

GETDEL oauth_state:9f2b7c1e         -> 1   # เลข 1 ตรงนี้คือ "ค่าที่เก็บไว้" เพราะเรา SET เป็นสตริง "1" ไม่ใช่รหัสว่าสำเร็จ
-- ยิงซ้ำด้วย state เดิม (replay) --
GETDEL oauth_state:9f2b7c1e         -> (ว่าง)
EXISTS oauth_state:9f2b7c1e         -> 0   # ส่วนเลข 0/1 ของ EXISTS คือ "ไม่มี/มี" คนละความหมายกับบรรทัดบน

-- state ที่ผู้โจมตีแต่งขึ้นเอง ไม่เคยมีใน Redis --
GETDEL oauth_state:attacker-made-it -> (ว่าง)

สองเคสล่างให้ผลเหมือนกันเป๊ะ — ทั้งการยิงซ้ำและการแต่งค่าเอง ได้ค่าว่างเหมือนกัน แปลว่าโค้ดฝั่งเราเขียนเงื่อนไขเดียวจบ: ถ้า GETDEL ไม่คืนค่า ให้ปฏิเสธ

แลก code เป็น token ที่ฝั่งเซิร์ฟเวอร์

ตอนนี้ wongnok API ถือ code อยู่ในมือ มันจะ POST ไปที่ token_endpoint ของ Keycloak พร้อม client_secret
ขั้นนี้เกิดที่เซิร์ฟเวอร์ ไม่ผ่านเบราว์เซอร์เลย — client_secret จึงไม่เคยออกจากเครื่องเรา ผลรันจริง:

$ curl -s -X POST $KC/realms/wongnok/protocol/openid-connect/token \
    -d client_id=wongnok-api -d client_secret=super-secret-123 \
    -d grant_type=authorization_code -d redirect_uri=$RU \
    --data-urlencode code@code.txt

HTTP 200
access_token        = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkI... (ยาว 1378)
expires_in          = 300
refresh_expires_in  = 1800
refresh_token       = eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkI... (ยาว 697)
token_type          = Bearer
id_token            = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkI... (ยาว 1069)
not-before-policy   = 0
session_state       = afOr4xGE8XiarnPyxNrzqiyn
scope               = openid profile email

📌 สองตัวเลขนี้จำไว้ เพราะเป็นค่า default จริงของ Keycloak 26.7: expires_in = 300 (access token อยู่ได้ 5 นาที) · refresh_expires_in = 1800 (refresh token 30 นาที)
หลายคนจำว่าเป็น 1 ชั่วโมง — ไม่ใช่
ส่วนตัวเลข "ยาว xxx" อย่าเอาไปเทียบว่าตรงกันเป๊ะ — ความยาวของ access_token ขึ้นกับว่า realm ของคุณใส่ role กับ client scope อะไรให้ user บ้าง ผมลองรันซ้ำบน realm เปล่า ๆ ที่มีแค่ client กับ user หนึ่งคนได้ access_token ยาว 1083 กับ refresh_token ยาว 673 ขณะที่ id_token ยังยาว 1069 เท่าเดิม
สิ่งที่ต้องตรงคือ ชื่อ field กับตัวเลขวินาที ไม่ใช่จำนวนตัวอักษร

แล้ว code ใช้ซ้ำได้ไหม? ผมเอาตัวเดิมไปแลกอีกรอบทันที:

{"error":"invalid_grant","error_description":"Code not valid"}
HTTP 400

code เป็นของใช้ครั้งเดียว เหมือนกัน — และนี่เป็นแบบที่ Keycloak บังคับให้เอง เราไม่ต้องเขียนอะไรเพิ่ม

เก็บ credential ไว้ใต้ ticket อายุ 30 วินาที

ตอนนี้ wongnok API ถือ token ครบแล้ว แต่ส่งกลับหาผู้ใช้ตรง ๆ ไม่ได้ เพราะช่องทางที่เหลืออยู่ตอนนี้คือ 302 ซึ่งส่งได้แค่ URL
วิธีแก้คือเก็บ credential ไว้ใน Redis ใต้ key ที่สุ่มขึ้นมาใหม่ชื่อ auth_ticket:yyy แล้วส่งกลับไปแค่ชื่อ key ผลรันจริงผ่าน go-redis:

PING -> PONG
Set(auth_ticket, 30s) err = <nil>
TTL -> 30s

แล้วเบราว์เซอร์ก็โดนเด้งไปหน้า frontend พร้อม ticket ติดไปใน URL: 302 Location: FRONTEND_URL/auth/success?ticket=yyy

5 · ขั้นที่ 3 — /exchange และหัวใจของทั้งบท (สไลด์หน้า 46)

  Browser        Frontend JS        wongnok API         Redis         Keycloak
     │                │                  │                │               │
     │  page loads -> JS reads ticket from URL            │               │
     │────────────────▶                  │                │               │
     │                │                  │                │               │
     │                │  POST /api/v1/auth/exchange   body {ticket: yyy}   <- axios
     │                │──────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  GETDEL auth_ticket:yyy        │
     │                │                  │────────────────▶               │
     │                │                  │                │               │
     │                │  200 {accessToken, refreshToken, bearerType, expiresAt}
     │                ◀──────────────────│                │               │
     │                │                  │                │               │
  Browser        Frontend JS        wongnok API         Redis         Keycloak

มาถึงคำถามที่บทนี้เกิดมาเพื่อตอบ:

❓ ในเมื่อจะแนบอะไรไปกับ URL ก็ได้ ทำไมไม่แนบ access_token ไปเลย จะได้ไม่ต้องมีขั้น /exchange

เขียนเป็น 302 Location: FRONTEND_URL/auth/success?access_token=eyJhbGci... ก็จบแล้วไม่ใช่เหรอ
คำตอบคือ อะไรก็ตามที่อยู่ใน URL จะถูกบันทึกไว้หลายที่โดยที่เราไม่ได้สั่ง — และนั่นคือความต่างระหว่าง query string กับ body ที่ บทเรียน 35 ปูไว้
หัวข้อนี้ผมรันวัดให้ดูทีละที่

ที่ที่ 1 — access log ของเซิร์ฟเวอร์

access log คือบันทึกที่เว็บเซิร์ฟเวอร์เขียนไว้ทุกครั้งที่มี request เข้ามา บรรทัดหนึ่งต่อหนึ่ง request โดยปกติจะบันทึก method กับ URL
ทั้ง gin.Logger(), nginx, และ load balancer ของ cloud ทุกเจ้าทำแบบนี้เป็น default
ผมเขียน middleware Go ที่พิมพ์แบบเดียวกัน แล้วยิงสองแบบเทียบ — แบบแรกส่ง ticket ไปใน query string แบบที่สองส่งไปใน body ของ POST:

=== แบบที่ 1 — ส่ง ticket ไปกับ query string (GET) ===
[access log] GET /api/v1/auth/success-bad?ticket=T-9f2b7c1e

=== แบบที่ 2 — ส่ง ticket ไปกับ body ของ POST ===
[access log] POST /api/v1/auth/exchange
   (handler เห็น body = {"ticket":"T-9f2b7c1e"})
   (client ได้ status=200 body={"accessToken":"eyJhbGciOiJSUzI1NiJ9...","refreshToken":"eyJhbGciOiJIUzUxMiJ9...","bearerType":"Bearer","expiresAt":1787368745})

บรรทัด access log ของแบบที่สองไม่มี ticket อยู่เลย — ทั้งที่ handler เห็น body เต็ม ๆ และตอบ token กลับไปครบ
เหตุผลง่ายมาก: body ไม่ได้เป็นส่วนหนึ่งของ request line ที่ log เขียน — ต้องตั้งใจเขียนโค้ดให้ log body ถึงจะโผล่ (ซึ่งไม่มีใครทำ เพราะมันหนักและมีของลับ)

ที่ที่ 2 — history ของเบราว์เซอร์

URL ที่ถูกโหลดจะไปอยู่ในประวัติการเข้าเว็บของเครื่องนั้น พร้อม query string ครบ ๆ
ผมเปิดหน้าจริงในเบราว์เซอร์แล้วถามมันว่าตอนนี้ URL คืออะไร:

> location.href
"http://127.0.0.1:19080/auth/success?ticket=SECRET-TICKET-abc123"

ค่านี้คือสิ่งเดียวกับที่เข้าไปอยู่ใน history — และมันอยู่ในเครื่องผู้ใช้ต่อไป แม้ปิดแท็บไปแล้ว ค้นเจอได้จากช่องค้นหาของเบราว์เซอร์

ที่ที่ 3 — header Referer

Referer (สะกดผิดมาตั้งแต่ปี 1996 และไม่มีใครแก้) คือ header ที่เบราว์เซอร์แนบไปบอกปลายทางว่า "ฉันมาจากหน้าไหน"
มันถูกแนบไปกับทุกไฟล์ย่อยที่หน้านั้นโหลด — รูป, ไฟล์ CSS, สคริปต์ analytics
ผมทำหน้าทดสอบที่โหลดรูปสองรูป: รูปหนึ่งจากเซิร์ฟเวอร์เดียวกัน อีกรูปจากเซิร์ฟเวอร์ภายนอก (คนละพอร์ต = คนละ origin) แล้วให้ทั้งสองฝั่งพิมพ์ Referer ที่ได้รับ นี่คือผลจริงจาก Google Chrome 151:

[frontend  :19080] GET /auth/success?ticket=SECRET-TICKET-abc123  Referer=""
[frontend  :19080] GET /logo.gif       Referer="http://127.0.0.1:19080/auth/success?ticket=SECRET-TICKET-abc123"
[third-party :19081] GET /tracker.gif  Referer="http://127.0.0.1:19080/"
[frontend  :19080] GET /favicon.ico    Referer="http://127.0.0.1:19080/auth/success?ticket=SECRET-TICKET-abc123"

ผลนี้ละเอียดกว่าที่สไลด์บอก และต้องอ่านให้ครบทั้งสามข้อ:

เหตุผลของข้อที่สองคือค่าปริยายของ Referrer-Policy ในเบราว์เซอร์สมัยใหม่คือ strict-origin-when-cross-origin ซึ่งแปลว่า "ข้ามเว็บเมื่อไหร่ ส่งแค่ชื่อโดเมน"
ดังนั้นถ้าใครบอกว่า "token ใน URL จะรั่วไปให้เว็บภายนอกทาง Referer" — ทุกวันนี้ไม่จริงแล้ว แต่มันยังรั่วเข้า access log ของทุกตัวที่ request วิ่งผ่าน (proxy, load balancer, CDN) และยังรั่วให้สคริปต์ทุกตัวใน origin เดียวกัน ซึ่งพอแล้วที่จะไม่ทำ

⚠️ ข้อยกเว้นที่คนเข้าใจผิดบ่อยที่สุด — Google Analytics ยังเห็น ticket อยู่

Referrer-Policy คุมได้แค่ Referer ซึ่งเป็น header ที่เบราว์เซอร์เป็นคนใส่ให้
แต่สคริปต์ analytics ไม่ได้พึ่ง header ตัวนั้นเลย — มันอ่าน document.location เองด้วย JavaScript แล้วส่ง URL เต็มไปกับ request ของตัวเอง (ใน GA4 คือ field page_location)
ผมจำลองให้ดูด้วยสคริปต์ที่ทำแบบเดียวกับ gtag เป๊ะ ๆ คือโหลดจากโดเมนภายนอก (:19081) แล้วยิงกลับไปที่โดเมนตัวเอง:

[analytics :19081] GET /gtag.js   Referer="http://127.0.0.1:19080/"
[analytics :19081] GET /collect?page_location=http%3A%2F%2F127.0.0.1%3A19080%2Fauth%2Fsuccess%3Fticket%3DSECRET-TICKET-abc123   Referer="http://127.0.0.1:19080/"

สังเกตสองบรรทัดนี้ให้ดี: Referer ของทั้งคู่ถูกตัดเหลือแค่โดเมนตามที่หัวข้อนี้บอกไว้จริง แต่ ticket เดินทางไปถึงเซิร์ฟเวอร์ภายนอกจนได้ — ผ่าน query string ที่สคริปต์เป็นคนสร้างเอง
นี่คือเหตุผลที่ทางแก้ต้องเป็น "ทำให้ของใน URL ไร้ค่า" ไม่ใช่ "ตั้ง Referrer-Policy ให้แน่นขึ้น"

เพราะฉะนั้น ticket จึงต้องเป็นของที่หลุดแล้วไม่มีค่า

ทางแก้ที่ Wongnok เลือกไม่ใช่ "ทำให้ URL ไม่รั่ว" (ทำไม่ได้) แต่คือ ยอมให้ของใน URL รั่ว แล้วทำให้ของชิ้นนั้นไร้ค่าตั้งแต่แรก

เทียบสองสิ่งticket (อยู่ใน URL)accessToken (อยู่ใน body ของ POST)
เดินทางยังไงquery string ของ 302 → แถบที่อยู่response body ของ POST /exchange
เข้า access log ไหมเข้าไม่เข้า
เข้า browser history ไหมเข้าไม่เข้า
โผล่ใน Referer ไหมโผล่ (สำหรับ origin เดียวกัน)ไม่โผล่
อายุ30 วินาที300 วินาที (5 นาที) ตามค่า Keycloak
ใช้ได้กี่ครั้งครั้งเดียว — ถูก GETDEL ทันทีที่แลกใช้ซ้ำได้จนหมดอายุ
ถ้าหลุดแล้วเกิดอะไรผู้เก็บได้ต้องชิงใช้ให้ทันภายใน 30 วินาที และต้องชิงก่อนเจ้าของด้วย — ซึ่งเจ้าของแลกไปแล้วตั้งแต่วินาทีแรกเข้าถึง API ในนามผู้ใช้ได้จนกว่าจะหมดอายุ
🧺 เทียบให้เห็นภาพ — ใบรับของที่ร้านซักรีด

เอาผ้าไปซัก ร้านไม่ได้ยัดผ้ากลับใส่มือคุณตอนนั้น เขาให้ใบรับของมาแผ่นเดียว
ใบนั้นไม่ใช่ผ้า — ทำหล่นกลางถนน คนเก็บได้ก็ยังไม่มีผ้าอยู่ในมือ ต้องเดินไปที่ร้านแล้วยื่นให้พนักงานถึงจะได้ผ้า

ticket ที่ติดมาใน URL คือใบรับของ · accessToken ที่ได้จาก POST /exchange คือผ้า
เว็บยอมให้ใบรับของไปนอนอยู่ทั้งสามที่ที่ผมวัดมาข้างบน (access log · history · Referer) เพราะของที่รั่วออกไปนั้นเอาไปทำอะไรเองไม่ได้

ที่ไม่เหมือนกัน: ใบรับของจริงมักใช้ได้เรื่อย ๆ จนกว่าจะมีคนมารับผ้า ใครหยิบไปก่อนก็ได้ผ้าไป
แต่ ticket ถูกบีบสองชั้นจนแทบไม่เหลือค่า: อยู่ได้ 30 วินาที และ ใช้ได้ครั้งเดียว — เจ้าของตัวจริงแลกไปตั้งแต่วินาทีแรกที่หน้าเว็บโหลดเสร็จ คนที่เก็บใบได้ทีหลังจึงเจอแค่ใบเปล่า

🗣 ศัพท์ที่เพิ่งเรียนไป
bearer token
token ประเภท “ใครถือก็ใช้ได้” ไม่ถามว่าเป็นใคร — accessToken ในบทนี้เป็นแบบนี้ จึงห้ามให้มันไปโผล่ในที่ที่ถูกบันทึกไว้ · “ตัวนี้เป็น bearer token ถ้าหลุดคือใช้ได้เลย เราไม่ควรส่งผ่าน query string นะครับ”
one-time token / single-use
ของที่ออกแบบให้แลกได้ครั้งเดียวแล้วตาย · ticket · state · code เป็นแบบนี้ทั้งหมด · ถาม AI ได้ว่า “ช่วยรีวิวหน่อยว่า flow นี้ทำให้ ticket เป็น one-time token จริงไหม”
access log
บันทึกที่เซิร์ฟเวอร์เขียนให้ทุก request โดยอัตโนมัติ (method + URL) · ประโยคที่ใช้บ่อยตอนรีวิวโค้ด: “อย่าใส่ค่านี้ใน query string เดี๋ยวมันเข้า access log”
credential
คำรวมของ “ของที่ใช้ยืนยันตัวตน” — ในบทนี้คือก้อน {accessToken, refreshToken, bearerType, expiresAt} ที่เราเก็บไว้ใน Redis · เจอตัวแปรชื่อ cred ในโค้ดคนอื่นเมื่อไหร่ ให้เดาไว้ก่อนว่าเป็นของชิ้นนี้

GETDEL ทำให้ ticket ใช้ได้ครั้งเดียวจริงไหม

รันจริงผ่าน go-redis v9.22.0:

GetDel #1 -> v={"accessToken":"eyJhbGciOiJSUzI1NiJ9...","refreshToken":"eyJhbGciOiJIUzUxMiJ9...","bearerType":"Bearer","expiresAt":1787368745} err=<nil>
GetDel #2 -> v="" err=redis: nil
errors.Is(err2, redis.Nil) = true

ก่อนดู handler ต้องมี struct ที่ใช้แกะ JSON ก้อนนั้นก่อน — นี่คือที่มาของชื่อ field ใน response ข้างบน (ถ้าข้ามบล็อกนี้ไป ตอนคอมไพล์จะเจอ undefined: Credential ทันที)
สองบล็อกถัดจากนี้เป็นสองไฟล์จริงใน package auth เดียวกัน — ก๊อปไปวางทั้งคู่แล้ว go build ./... ผ่าน ไม่ต้องเติมอะไรเอง (ทดสอบบน Go 1.26.5 · gin v1.12.0 · go-redis v9.22.0):

// internal/auth/credential.go
package auth

// ก้อนที่เราเก็บลง Redis ตอนขั้น 2 และคายกลับให้เบราว์เซอร์ตอนขั้น 3
type Credential struct {
    AccessToken  string `json:"accessToken"`
    RefreshToken string `json:"refreshToken"`
    BearerType   string `json:"bearerType"`
    ExpiresAt    int64  `json:"expiresAt"`
}

📌 bearerType เป็นชื่อที่ API ของเราตั้งเอง ไม่ใช่ชื่อมาตรฐานของ OAuth — Keycloak ตอบกลับมาว่า token_type = Bearer (ดูผลรัน token endpoint ข้างบน) แล้วเราเปลี่ยนเป็น camelCase ให้เข้าชุดกับ field อื่นใน response ของเรา
ถ้าไปหาคำว่า bearerType ใน RFC 6749 จะไม่เจอ เพราะมันไม่ได้อยู่ในนั้น

หน้าตาของ handler /exchange ที่ถูกต้องจึงสั้นมาก — และบรรทัดที่สำคัญที่สุดคือบรรทัดเดียว ที่ใช้ GetDel แทน Get:

// internal/auth/exchange.go — อยู่ package เดียวกับ credential.go จึงเห็น Credential
package auth

import (
    "encoding/json"
    "errors"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/redis/go-redis/v9"
)

func exchangeHandler(rdb *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        var req struct{ Ticket string `json:"ticket" binding:"required"` }
        if err := c.ShouldBindJSON(&req); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
            return
        }

        // GetDel = อ่านแล้วลบในคำสั่งเดียว -> ticket ใช้ได้ครั้งเดียวเท่านั้น
        raw, err := rdb.GetDel(c, "auth_ticket:"+req.Ticket).Result()

        switch {
        case errors.Is(err, redis.Nil):        // ไม่มี ticket นี้: หมดอายุ / ใช้ไปแล้ว / แต่งมาเอง
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid ticket"})
            return
        case err != nil:                       // Redis มีปัญหาจริง ๆ คนละเรื่องกัน
            c.JSON(http.StatusInternalServerError, gin.H{"error": "storage error"})
            return
        }

        var cred Credential
        if err := json.Unmarshal([]byte(raw), &cred); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "corrupt ticket"})
            return
        }
        c.JSON(http.StatusOK, cred)   // token เดินทางใน response body ของ POST เท่านั้น
    }
}

📌 สังเกตว่า switch แยกสอง error ออกจากกันคนละเคส — redis.Nil ตอบ 401 Unauthorized (ผู้ใช้ผิด) ส่วน error ตัวอื่นตอบ 500 (เราผิด)
ถ้าเขียนรวมเป็น if err != nil { 401 } ก้อนเดียว เวลาที่ Redis ล่มจริง ๆ ผู้ใช้ทุกคนจะเห็นข้อความว่า "ticket ไม่ถูกต้อง" แล้วคุณจะไล่หาสาเหตุผิดทางทั้งวัน

ครั้งที่สองได้ redis: nil ซึ่งเป็น sentinel error ตัวเดียวกับที่เจอใน บทเรียน 32 — แปลว่า "ไม่มี key นี้" ไม่ใช่ "Redis พัง" · สอง error นี้ต้องแยกให้ออกในโค้ด

ทายก่อนรัน — สมมติมีคนเขียน /exchange ด้วย GET เฉย ๆ แล้วลืมบรรทัดที่ลบ ticket ทิ้ง:

// เศษโค้ดสองบรรทัด (ไม่ใช่ไฟล์เต็ม) — เวอร์ชันที่มีบั๊ก: อ่านอย่างเดียว ไม่ลบ
raw, err := rdb.Get(ctx, "auth_ticket:"+ticket).Result()

ทายว่าถ้าเอา ticket ตัวเดิมมายิงซ้ำสามครั้ง จะได้อะไรบ้าง
และคำถามที่สำคัญกว่า: ผู้ใช้คนนั้นจะสังเกตเห็นอะไรผิดปกติไหม?
ลองคิดต่อด้วยว่า ticket ตัวนั้นไปนอนอยู่ที่ไหนบ้างแล้ว (ย้อนดูหัวข้อ 5 สามที่ที่ผมวัดมา)

ผลรันจริงจาก Redis 8.10.1 — ตั้ง TTL 30 วินาทีเหมือนเดิมทุกอย่าง ต่างแค่ใช้ GET แทน GETDEL:

GET auth_ticket:bug999 ครั้งที่ 1 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 2 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 3 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
EXISTS auth_ticket:bug999         -> 1

คืนค่าเดิมทุกครั้ง และ key ยังอยู่ — เทียบกับเวอร์ชันที่ถูก ที่ครั้งที่สองได้ redis: nil ไปแล้ว

คำตอบของคำถามที่สอง: ผู้ใช้จะไม่เห็นอะไรผิดปกติเลย — เขาล็อกอินสำเร็จ ใช้งานเว็บได้ตามปกติทุกอย่าง เพราะครั้งแรกที่เขาแลก ก็ได้ token ถูกต้อง
ช่องโหว่อยู่ที่ ticket ตัวเดิมยังใช้ได้อีก 30 วินาที และตอนนี้มันไปนอนอยู่ใน access log ของเซิร์ฟเวอร์ · history ของเบราว์เซอร์ · และ Referer ที่ส่งให้ทุกไฟล์ใน origin เดียวกัน
ใครก็ตามที่อ่าน log ได้ — คนในทีมที่เปิด dashboard ดู, ระบบรวม log, หรือคนที่เจาะเข้าเครื่อง log — หยิบไปยิงซ้ำได้ token ชุดเดียวกับผู้ใช้จริง

และเรื่องที่แสบที่สุดคือมันตรวจไม่เจอ: ไม่มี error ไม่มี log เตือน ผู้ใช้ไม่ร้องเรียน เทสก็ผ่าน เพราะเทสส่วนใหญ่แลก ticket แค่ครั้งเดียวแล้วเช็คว่าได้ token
เทสที่จับบั๊กนี้ได้ต้องเป็นเทสที่ยิงสองครั้งแล้ว assert ว่าครั้งที่สองต้องล้มเหลว — ซึ่งเป็นเทสที่คนไม่ค่อยเขียน เพราะมันเช็ค "สิ่งที่ต้องไม่เกิด"

6 · ขั้นที่ 4 — /logout (สไลด์หน้า 47)

  Browser        Frontend JS        wongnok API         Redis         Keycloak
     │                │                  │                │               │
     │                │  POST /api/v1/auth/logout   body {refreshToken}   <- axios
     │                │──────────────────▶                │               │
     │                │                  │                │               │
     │                │                  │  POST /logout  refresh_token + client_secret
     │                │                  │────────────────────────────────▶
     │                │                  │                │               │
     │                │                  │  204 No Content                │
     │                │                  ◀────────────────────────────────│
     │                │                  │                │               │
     │                │  204 No Content  │                │               │
     │                ◀──────────────────│                │               │
     │                │                  │                │               │
  Browser        Frontend JS        wongnok API         Redis         Keycloak

ขั้นนี้สั้นที่สุด และเป็น AJAX ล้วน ๆ ไม่มี redirect เลย
สิ่งที่ต้องเข้าใจคือ การลบ token ทิ้งจากฝั่ง frontend ไม่ใช่การ logout — token ที่ผู้ใช้เคยได้ไปยังใช้ได้อยู่จนหมดอายุ ถ้ามีใครก๊อปไว้ก่อน
การ logout จริงคือบอก Keycloak ให้ปิด session นั้นทิ้ง ซึ่งต้องส่ง refresh_token ไปพร้อม client_secret

ผมยิงของจริงไปที่ end_session_endpoint ของ Keycloak 26.7.2 สามรอบ:

=== 1) logout ครั้งแรก ===
$ curl -X POST $KC/realms/wongnok/protocol/openid-connect/logout \
    -d client_id=wongnok-api -d client_secret=super-secret-123 \
    --data-urlencode refresh_token@rt.txt
HTTP 204
response body ยาว 0

=== 2) ยิง logout ซ้ำด้วย refresh_token ตัวเดิม ===
HTTP 204
(body ว่าง)

=== 3) หลัง logout แล้วเอา refresh_token เดิมไปขอ token ใหม่ ===
{"error":"invalid_grant","error_description":"Session not active"}
HTTP 400

📌 อ่านผลนี้ให้ครบสามชั้น
· 204 No Content คือสถานะ "สำเร็จ แต่ไม่มีอะไรจะตอบ" — ไม่มี body เลยจริง ๆ (ยาว 0 ไบต์) ตรงกับที่ บทเรียน 34 ปูไว้
· ยิงซ้ำก็ยังได้ 204 ไม่ใช่ 400 — เพราะ logout เป็น idempotent (สั่งซ้ำกี่ครั้งผลลัพธ์เท่าเดิม) ดังนั้นอย่าเขียนโค้ดที่แปล 204 ครั้งที่สองว่า "logout ไม่สำเร็จ"
· ชั้นที่พิสูจน์ว่ามันทำงานจริงคือชั้นที่สาม: refresh_token ตัวนั้นตายแล้ว ได้ Session not active กลับมา

⚠️ 204 ไม่ได้แปลว่า access_token ที่แจกไปแล้วตายด้วย

Keycloak ปิด session และฆ่า refresh_token ให้ — แต่ access_token ที่ผู้ใช้ถืออยู่เป็น JWT ที่ตรวจได้ด้วยลายเซ็นอย่างเดียว ไม่ได้ถาม Keycloak ทุกครั้ง
แปลว่ามันยังใช้ได้ต่อจนกว่าจะครบ expires_in ซึ่งค่า default คือ 300 วินาที
นี่คือราคาที่ต้องจ่ายของการใช้ JWT แบบไม่เช็คทุก request — และเป็นเหตุผลที่ Keycloak ตั้ง access token ไว้แค่ 5 นาที

🪪 เทียบให้เห็นภาพ — บัตรพนักงานที่ยามดูแค่วันหมดอายุบนบัตร

ตึกออฟฟิศหลายที่ ยามหน้าประตูไม่ได้โทรเช็คทะเบียนพนักงานทุกครั้งที่มีคนเดินเข้า — เขาแค่ดูว่าบัตรเป็นของจริงไหม และวันหมดอายุที่พิมพ์อยู่บนบัตรยังไม่ถึงหรือเปล่า
วันที่ HR ลบชื่อคุณออกจากระบบ ทะเบียนกลางเปลี่ยนทันที แต่บัตรที่ยังอยู่ในกระเป๋าคุณหน้าตาถูกต้องทุกอย่าง ยามจึงยังปล่อยผ่าน จนกว่าจะถึงวันที่พิมพ์ไว้บนบัตร

access_token แบบ JWT คือบัตรใบนั้น — API ตรวจแค่ลายเซ็น กับเวลาหมดอายุที่ติดมาในตัว token เอง ไม่ได้ถาม Keycloak ทุก request
POST /logout จึงทำได้แค่ปิดทะเบียนกลาง (session ตาย · refresh_token ใช้ไม่ได้แล้ว) ส่วนบัตรที่แจกออกไปแล้วยังใช้ได้จนครบ expires_in = 300 วินาที ตามที่รันให้ดูข้างบน

ที่ไม่เหมือนกัน: บัตรพนักงานยังยึดคืนได้ถ้าคุณเดินมาส่ง แต่ access_token ถูกก๊อปเก็บไว้กี่ชุดก็ได้และเรียกคืนไม่ได้เลย — นี่คือเหตุผลที่ทางแก้ไม่ใช่ “ทำให้ยึดคืนได้” แต่คือตั้งอายุให้สั้นจนขโมยไปแล้วไม่คุ้ม

🗣 ศัพท์ที่เพิ่งเรียนไป
JWT (JSON Web Token)
token ที่พกข้อมูลกับลายเซ็นไว้ในตัวเอง ผู้รับตรวจได้เองโดยไม่ต้องถามใคร · “access token เราเป็น JWT ใช่ไหมครับ แปลว่าสั่งให้ตายกลางคันไม่ได้ใช่หรือเปล่า”
revoke
“ยกเลิกของที่แจกไปแล้ว” · refresh_token revoke ได้เพราะต้องกลับไปถาม Keycloak ทุกครั้ง ส่วน JWT ที่ตรวจด้วยลายเซ็นอย่างเดียว revoke ไม่ได้ · ถาม AI ว่า “ถ้าต้อง revoke access token ให้ตายทันที มีทางเลือกอะไรบ้าง แลกกับอะไร”
idempotent
“สั่งซ้ำกี่ครั้งผลลัพธ์ก็เท่าเดิม” — /logout เป็นแบบนี้ ยิงสองครั้งได้ 204 ทั้งคู่ · “endpoint นี้ idempotent ไหมครับ ถ้า client retry แล้วจะพังไหม”
session
“รอบการใช้งานหนึ่งรอบของผู้ใช้” ที่ฝั่ง Keycloak จำไว้ · logout คือการปิด session นั้นทิ้ง · ระวังอย่าสับสนกับ session ของเว็บเราเอง — flow ในบทนี้ฝั่งเราไม่มี session เลย มีแต่ Redis ที่เก็บของอายุสั้น

7 · ตารางสรุปทั้งสี่ endpoint

endpointใครเป็นคนเรียกเรียกยังไงแตะ Redis ยังไงตอบอะไรกลับ
GET /api/v1/auth/login Browser window.location
ห้าม AJAX
SET oauth_state:xxx
TTL 300 วินาที
302 + Location: ไปหน้า login ของ Keycloak
GET /api/v1/auth/callback Browser
(เดินตาม 302 ของ Keycloak)
ไม่มีใครสั่ง — เบราว์เซอร์เดินเองตาม redirect GETDEL oauth_state:xxx แล้ว
SET auth_ticket:yyy TTL 30 วินาที
302 + Location: ไป FRONTEND_URL/auth/success?ticket=yyy
POST /api/v1/auth/exchange Frontend JS axios.post (AJAX)
body {ticket}
GETDEL auth_ticket:yyy 200 + body {accessToken, refreshToken, bearerType, expiresAt}
POST /api/v1/auth/logout Frontend JS axios.post (AJAX)
body {refreshToken}
ไม่แตะเลย 204 No Content (body ว่าง)

อ่านตารางนี้ตามแนวตั้งของคอลัมน์ "เรียกยังไง" แล้วจะเห็นเส้นแบ่งชัด ๆ
สองแถวบน = โลกของ redirect — เบราว์เซอร์เป็นคนเดิน · ส่งข้อมูลได้แค่ทาง URL · ทุกอย่างถูก log
สองแถวล่าง = โลกของ AJAX — JavaScript เป็นคนเรียก · ส่งข้อมูลทาง body ได้ · ไม่ถูก log
ทั้ง flow นี้ออกแบบมาเพื่อข้ามจากโลกบนไปโลกล่างให้เร็วที่สุด โดยมี ticket เป็นสะพาน

8 · Redis อยู่ตรงนี้ทำไม

คำถามที่ควรถามคือ ทำไมไม่เก็บใส่ตัวแปรใน Go ไปเลย? — คำตอบมีสามข้อ และข้อสุดท้ายสำคัญที่สุด

ข้อ 1 — ของสองชิ้นนี้ต้อง "หายเอง"

ทั้ง oauth_state และ auth_ticket เป็นของที่ถ้าไม่มีใครมาเอา ต้องหายไปเอง
ผู้ใช้กด login แล้วปิดแท็บทิ้ง = ไม่มีใครมาแลก state นั้นตลอดกาล · ผู้ใช้เจอเน็ตหลุดตอนหน้า success = ไม่มีใครมาแลก ticket
ถ้าเก็บใน map ของ Go ของพวกนี้จะค้างอยู่จนกว่าจะ restart — หน่วยความจำรั่ว และต้องเขียนโค้ดกวาดขยะเอง
Redis ตั้ง TTL แล้วลืมได้เลย ผมรันให้ดูด้วย TTL 3 วินาที (ย่อจาก 30 เพื่อให้ดูทัน):

t=0s  TTL -> 3
t=2s  TTL -> 1
t=4s  TTL -> -2
t=4s  GET -> (ว่าง)
t=4s  EXISTS -> 0

📌 TTL คืนเลขติดลบสองค่าที่ความหมายต่างกัน อย่าจำสลับ
ผมรันเทียบให้ดู:

TTL forgot_ttl (ตั้ง SET โดยไม่ใส่ EX) -> -1   # key มีอยู่ แต่ไม่มีวันหมดอายุ = อยู่ตลอดไป
TTL ของคีย์ที่ไม่มีอยู่จริง             -> -2   # ไม่มี key นี้แล้ว

-1 คือสัญญาณอันตรายในบทนี้: ถ้าลืมใส่ EX ตอน SET auth_ticket ticket ตัวนั้นจะอยู่ตลอดไป ไม่ใช่ 30 วินาที

ข้อ 2 — ต้องอ่านแล้วลบให้ได้ในจังหวะเดียว

ทั้งสองจุดใช้ GETDEL ซึ่ง Redis รับประกันว่าเป็นคำสั่งเดียว — ไม่มีช่องว่างระหว่าง "อ่าน" กับ "ลบ" ให้ใครแทรก
ถ้าเขียนเองเป็น Get() แล้วค่อย Del() สองบรรทัด จะมีช่วงเสี้ยววินาทีที่ request สองอันอ่านค่าเดียวกันได้พร้อมกัน — ซึ่งพังทั้งความเป็นของใช้ครั้งเดียว

🚇 เทียบให้เห็นภาพ — ประตูขาออกของรถไฟฟ้า

ซื้อตั๋วเที่ยวเดียวไว้ พอถึงปลายทางคุณยื่นมันเข้าประตูขาออก — ประตูอ่านค่าแล้วกลืนตั๋วไปเลยในจังหวะเดียวกัน ไม่มีคืน
ลองนึกภาพประตูอีกแบบ: อ่านค่าแล้วคืนตั๋วให้คุณ โดยบอกว่า “เดี๋ยวมีคนมาเก็บทีหลัง” — ระหว่างนั้นใครหยิบตั๋วใบนั้นไปยื่นซ้ำก็ออกได้ฟรี

GETDEL คือประตูแบบแรก · เขียน Get แล้วค่อย Del คนละบรรทัด คือประตูแบบหลัง
ช่องว่างระหว่างสองบรรทัดนั้นสั้นมากจนตอนเทสในเครื่องตัวเองไม่มีทางเห็น แต่บนของจริงที่มี request วิ่งเข้ามาพร้อมกันหลายอัน มันกว้างพอให้สองคนอ่านค่าเดียวกันได้

ที่ไม่เหมือนกัน: ประตูรถไฟฟ้ากลืนตั๋วตอนคุณออก คือจบการเดินทางพอดี แต่ GETDEL เกิดตอนเริ่มใช้งาน — พอ ticket ตายไปแล้ว สิ่งที่คุณเพิ่งได้มาคือ access_token ที่ยังใช้ต่อได้จนครบอายุ 300 วินาทีของมัน

ข้อ 3 — แอปของจริงรันหลายก้อนพร้อมกัน

ตอน deploy จริงเราไม่ได้รัน wongnok API ตัวเดียว แต่รันสำเนาหลายตัวหลัง load balancer
ทีนี้ดูเส้นทางในหัวข้อ 4 อีกรอบ: /login กับ /callback เป็นคนละ request — ไม่มีอะไรรับประกันว่าสองอันจะวิ่งเข้าเครื่องเดียวกัน
ถ้า state ถูกเก็บไว้ใน RAM ของเครื่องที่รับ /login แล้ว /callback ดันวิ่งเข้าอีกเครื่อง ผู้ใช้จะ login ไม่ผ่านแบบสุ่ม — บั๊กที่หาสาเหตุยากที่สุดประเภทหนึ่ง เพราะบนเครื่อง dev ที่รันก้อนเดียวมันไม่เคยเกิด
Redis เป็นของกลางที่ทุกก้อนเห็นชุดเดียวกัน — เหตุผลเดียวกับที่ บทเรียน 32 อธิบายเรื่อง cache ไว้

🗣 ศัพท์ที่เพิ่งเรียนไป
atomic
“ทำทั้งชุดจบในจังหวะเดียว ไม่มีใครแทรกกลางได้” — GETDEL เป็นคำสั่ง atomic · ประโยคที่ใช้ได้จริงตอนรีวิวโค้ด: “ตรงนี้ต้อง atomic นะ ไม่งั้นสอง request อ่านค่าเดียวกันได้”
race condition
บั๊กที่เกิดเพราะสองงานวิ่งชนกันในจังหวะที่พอดีเป๊ะ · เจอยากมากตอนเทส เพราะต้องบังเอิญชนกันพอดี · ถาม AI ว่า “โค้ดนี้มี race condition ตรงไหนไหม ถ้ามี request เข้ามาพร้อมกันสองอัน”
TTL (time to live)
อายุที่ตั้งให้ key ใน Redis · เลขบวก = เหลืออีกกี่วินาที · -1 = ไม่มีวันหมดอายุ · -2 = ไม่มี key นี้แล้ว · “key นี้ TTL เท่าไหร่ครับ เผลอลืมใส่ EX ตอน SET รึเปล่า”
stateless
“ตัวแอปไม่จำอะไรไว้ในตัวเอง” — เหตุผลที่ state กับ ticket ต้องไปอยู่ใน Redis ไม่ใช่ตัวแปรใน Go เพราะของจริงรันหลายก้อนหลัง load balancer · “service เราเป็น stateless ใช่ไหม ถ้าใช่ ของแบบนี้ต้องไปอยู่ Redis”

9 · state กัน CSRF ยังไง และทำไมต้องลบทิ้ง

CSRF ย่อจาก Cross-Site Request Forgery — การที่เว็บของคนอื่น หลอกให้เบราว์เซอร์ของคุณยิง request ไปที่เว็บเรา โดยคุณไม่รู้ตัว
ในบริบท OAuth มันมีหน้าตาเฉพาะตัว ที่เรียกว่า login CSRF — ไล่ทีละขั้น:

ถ้า /callback ไม่เช็ค state:

  1. ผู้โจมตี login เข้า Wongnok ด้วย "บัญชีของตัวเอง" ตามปกติ
  2. แต่พอ Keycloak เด้ง 302 กลับมาที่ /callback?code=AAA  เขา "ไม่กดต่อ"
     -> เขาคัดลอก URL นั้นเก็บไว้ ตอนนี้ code=AAA ยังไม่ถูกใช้
  3. เขาหลอกให้เหยื่อคลิก URL นั้น (ส่งทางแชท / ฝังเป็นรูปในหน้าเว็บ)
  4. เบราว์เซอร์ของเหยื่อยิง GET /api/v1/auth/callback?code=AAA
  5. wongnok API แลก code=AAA -> ได้ token ของ "บัญชีผู้โจมตี"
     -> เหยื่อถูกล็อกอินเข้าบัญชีของผู้โจมตี โดยคิดว่าเป็นบัญชีตัวเอง
  6. เหยื่อกดบันทึกสูตรอาหาร / ผูกบัตร / อัปโหลดรูป
     -> ทุกอย่างไปอยู่ในบัญชีที่ผู้โจมตีเข้าถึงได้

state เข้ามาตัดวงจรนี้ที่ขั้น 4 — จุดที่ request วิ่งเข้ามาถึงเรา
เพราะเราไม่รับ /callback ที่ไม่มี state ซึ่งเราเป็นคนออกให้ URL ที่ผู้โจมตีจะใช้ได้จึงต้องเป็น URL ที่มี state จริง ไม่ใช่ค่าที่เขาแต่งขึ้นมาเอง
ผลรันในหัวข้อ 4 แสดงให้เห็นแล้วว่าค่าที่แต่งเองได้อะไรกลับมา:

GETDEL oauth_state:attacker-made-it -> (ว่าง)   # ไม่มีใน Redis = ปฏิเสธ
⚠️ ทำไม "เช็คว่ามี" อย่างเดียวไม่พอ ต้อง "ลบ" ด้วย

สมมติเราเขียนแค่ EXISTS oauth_state:xxx แล้วปล่อย key ไว้ 5 นาทีตาม TTL
ผู้โจมตีจะได้ช่องว่างยาว 5 นาทีในการยิง URL เดิมซ้ำกี่ครั้งก็ได้ — และถ้าโค้ดฝั่งเรามีจุดไหนที่ทำงานไม่สำเร็จแล้วให้ลองใหม่ได้ เขาก็ยิงรัวได้ฟรี ๆ
พอเปลี่ยนเป็น GETDEL ช่องว่างนั้นหดเหลือครั้งเดียว ผมรันพิสูจน์ไว้แล้วในหัวข้อ 4:

GETDEL oauth_state:9f2b7c1e -> 1          # ครั้งแรก ผ่าน
GETDEL oauth_state:9f2b7c1e -> (ว่าง)     # ครั้งที่สอง ไม่มีแล้ว
EXISTS oauth_state:9f2b7c1e -> 0

ผลข้างเคียงที่ดีอีกอย่าง: เคส "ยิงซ้ำ" กับเคส "แต่ง state เอง" ให้ผลเหมือนกันเป๊ะ (ค่าว่างทั้งคู่) — โค้ดจึงเขียนเงื่อนไขเดียวจบ ไม่ต้องแยกสองกรณี และไม่บอกใบ้ผู้โจมตีว่า state ที่เขาเดามาถูกหรือผิด

⚠️ ครึ่งที่หายไป — state ต้องผูกกับเบราว์เซอร์คนนั้น ไม่ใช่แค่ "เราเคยออกให้"

ตรงนี้เป็นจุดที่ diagram ในสไลด์บอกไม่ครบ และเป็นเรื่องที่ควรรู้ก่อนเอาไปเขียนของจริง
ลองไล่การโจมตีข้างบนอีกรอบ แต่คราวนี้ผู้โจมตีไม่แต่ง state เอง — เขาไปกด /login ที่เว็บเราตามปกติ เพื่อให้ระบบออก state ให้เขาจริง ๆ แล้วค่อยเอา URL /callback?code&state ของเขาไปหลอกเหยื่อ
ตอนเหยื่อคลิก เราจะ GETDEL เจอ เพราะ key นั้นมีอยู่จริง — ด่านนี้ผ่านฉลุย

สาเหตุคือการเก็บแค่ oauth_state:xxx = "1" ตอบได้แค่คำถามว่า "ค่านี้เราเคยออกให้ใครสักคนไหม"
แต่คำถามที่ต้องตอบจริง ๆ คือ "ค่านี้เราออกให้เบราว์เซอร์ตัวที่กำลังยิงเข้ามาอยู่นี้หรือเปล่า"

ทางแก้คือผูก state เข้ากับเบราว์เซอร์: ตอน /login ให้ตั้ง cookie ที่สุ่มขึ้นมาคู่กันไปด้วย (HttpOnly · SameSite=Lax) แล้วเก็บลง Redis เป็น oauth_state:xxx = <ค่าใน cookie> แทนที่จะเก็บเป็น "1"
ตอน /callback ก็เทียบค่าที่ GETDEL ได้ กับ cookie ที่ติดมากับ request — ไม่ตรงกันเมื่อไหร่คือคนละเบราว์เซอร์ ให้ปฏิเสธ
นี่คือสิ่งที่ RFC 6749 §10.12 หมายถึงตอนที่มันเขียนว่า state ต้องผูกกับ session ของ user agent

🧭 สรุปกฎเดียวที่ครอบทั้งบท — ของทุกชิ้นในบทนี้ที่ เดินทางผ่าน URL ถูกออกแบบให้เป็นของใช้ครั้งเดียว อายุสั้น:
· state — TTL 5 นาที · GETDEL ทิ้งเมื่อใช้ (และควรผูกกับ cookie ตามกล่องข้างบน)
· code — Keycloak บังคับเอง (ยิงซ้ำได้ Code not valid HTTP 400)
· ticket — TTL 30 วินาที · GETDEL ทิ้งเมื่อแลก
ส่วนของที่มีค่าจริง (access_token, refresh_token, client_secret) ไม่เคยผ่าน URL แม้แต่ครั้งเดียวตลอดทั้ง flow

ทดสอบความเข้าใจ

1. ทำไม /api/v1/auth/login ต้องเรียกด้วย window.location เรียกด้วย axios.get() ไม่ได้?

ปัญหาไม่ได้อยู่ที่ axios ตามไม่ได้ — มันตามได้ แต่ผลที่ได้กลับมาเป็น สตริง HTML ในตัวแปร JavaScript หน้าจอผู้ใช้ไม่ขยับ
ผม curl ไปที่ authorization_endpoint จริง ได้ HTML กลับมา 6882 ไบต์ — เอาไปให้ axios ก็ทำอะไรต่อไม่ได้
และเหตุผลที่หนักกว่านั้นคือเรื่องความไว้ใจ: ผู้ใช้กำลังจะพิมพ์รหัสผ่าน เขาต้องเห็นชื่อโดเมนของ Keycloak บนแถบที่อยู่ ถ้าเว็บเราแสดงหน้า login แทนได้ นั่นคือหน้าตาของฟิชชิง
ตัวเลือกที่ 2 ผิดเพราะ endpoint นี้ไม่ได้ตอบ JSON เลย · ตัวเลือกที่ 1 ผิดเพราะไม่เกี่ยวกับ header
ตัวเลือกที่ 4 หลอกดีเพราะฟังดูสมเหตุสมผล แต่ axios ในเบราว์เซอร์เดินตาม redirect ให้อัตโนมัติอยู่แล้ว

2. ผมยิง ticket ไปสองแบบแล้วดู access log แบบเดียวกับ gin.Logger() — ผลจริงเป็นยังไง?

ผลรันจริงสองบรรทัดนี้คือหัวใจของทั้งบท:

[access log] GET /api/v1/auth/success-bad?ticket=T-9f2b7c1e
[access log] POST /api/v1/auth/exchange

ทั้งที่ handler ของแบบ POST เห็น body เต็ม ๆ ({"ticket":"T-9f2b7c1e"}) และตอบ token กลับไปครบ
เหตุผล: log เขียนจาก request line ซึ่งประกอบด้วย method กับ URI เท่านั้น — body ไม่ได้อยู่ในนั้น ต้องตั้งใจเขียนโค้ด log body ถึงจะโผล่
ตัวเลือกที่ 2 ผิดชัด: ไม่มีเซิร์ฟเวอร์ตัวไหนเดาได้ว่าพารามิเตอร์ไหนเป็นความลับ
ตัวเลือกที่ 4 ผิดกลับด้าน และ "query string ถูกเข้ารหัส" ก็ไม่จริง — HTTPS เข้ารหัสระหว่างทาง แต่เซิร์ฟเวอร์ปลายทางถอดออกมาเขียน log อยู่ดี

3. โค้ด /exchange ใช้ Get() แทน GetDel() คือลืมลบ ticket ทิ้ง — จะเกิดอะไร และใครจะสังเกตเห็น?

ผลรันจริงกับ Redis 8.10.1 — GET คีย์เดิมสามครั้งได้ค่าเดิมทั้งสามครั้ง และ EXISTS ยังคืน 1 · เทียบกับ GETDEL ที่ครั้งที่สองได้ redis: nil ไปแล้ว
ตัวเลือกที่ 3 คือคำตอบที่คนเลือกเยอะที่สุดและผิดที่สุด: TTL ไม่ได้ทำให้ใช้ครั้งเดียว มันแค่ทำให้หมดอายุ — ระหว่าง 30 วินาทีนั้นใช้ได้ไม่จำกัดครั้ง
และ 30 วินาทีนั้นเริ่มนับตั้งแต่ /callback เขียน key ลง Redis ซึ่งเป็นวินาทีเดียวกับที่ ticket โผล่ใน access log แล้ว — ผู้อ่าน log มีเวลาเหลือเฟือ
ตัวเลือกที่ 1 กับ 2 ผิดเรื่องพฤติกรรมของ Redis: GET อ่านซ้ำได้ไม่จำกัด และใช้กับ key ที่มี TTL ได้ปกติ
สิ่งที่ทำให้บั๊กนี้อันตรายคือมันไม่มีอาการ: ไม่ error ไม่มี log เตือน ผู้ใช้ไม่ร้องเรียน เทสที่จะจับได้ต้องยิงสองครั้งแล้ว assert ว่าครั้งที่สองต้องล้มเหลว

4. ยิง POST /logout ไป Keycloak สำเร็จแล้วได้ 204 — ตอนนี้อะไรตายแล้ว อะไรยังไม่ตาย?

ผมรันครบสามรอบ: logout ครั้งแรกได้ HTTP 204 body ยาว 0 ไบต์ · ยิงซ้ำได้ 204 อีกครั้ง (idempotent) · แล้วเอา refresh_token เดิมไปขอ token ใหม่ได้ {"error":"invalid_grant","error_description":"Session not active"} พร้อม HTTP 400
ส่วน access_token ยังไม่ตาย เพราะมันเป็น JWT ที่ตรวจด้วยลายเซ็น — API ที่รับมันไม่ได้ถาม Keycloak ทุกครั้งว่า session ยังอยู่ไหม
ตัวเลือกที่ 4 กลับด้าน · ตัวเลือกที่ 3 เข้าใจ 204 ผิด: 204 No Content แปลว่า สำเร็จแล้ว แต่ไม่มีอะไรจะตอบ ไม่ใช่ "รับเข้าคิว" (นั่นคือ 202 Accepted)
ผลในทางปฏิบัติ: ช่วง 300 วินาทีนั้นคือหน้าต่างที่ token ที่ถูกขโมยไปก่อนหน้ายังใช้ได้อยู่ — เป็นราคาที่ต้องจ่ายของการใช้ JWT แบบไม่เช็คทุก request และเป็นเหตุผลที่ Keycloak ตั้งอายุไว้แค่ 5 นาที

🛠️ ภารกิจ — เดินทั้งสี่ขั้นด้วยมือ แล้วดูว่า ticket โผล่ที่ไหนบ้าง

เป้าหมายไม่ใช่ "ทำให้ login ผ่าน" — เป้าหมายคือให้คุณเห็นด้วยตาตัวเองว่า อะไรที่อยู่ใน URL มันไปโผล่ที่ไหน แล้วค่อยเข้าใจว่าทำไมต้องมี /exchange

  1. ตั้ง Redis ขึ้นมาก่อน: docker run -d --rm --name lab-redis -p 6379:6379 redis:8-alpine
    เช็คว่ารันแล้วด้วย docker exec lab-redis redis-cli PING ต้องได้ PONG
  2. เล่นกับ TTL ให้เข้าใจสามสถานะ: ใช้ redis-cli ตรง ๆ
    SET k1 v EX 3 แล้วสั่ง TTL k1 รัว ๆ จนได้ -2 · SET k2 v (ไม่ใส่ EX) แล้ว TTL k2
    ตอบให้ได้ว่า -1 กับ -2 ต่างกันยังไง และข้อไหนคือบั๊กถ้าเจอกับ auth_ticket:
  3. พิสูจน์ GETDEL: SET t1 "credential" EX 30 แล้วสั่ง GETDEL t1 สองครั้ง · จากนั้นทำซ้ำด้วย GET สามครั้ง
    จดผลทั้งสองแบบมาเทียบ — นี่คือความต่างระหว่างโค้ดที่ถูกกับโค้ดที่มีบั๊ก
  4. ทำ access log ของตัวเอง: เขียน Go server เล็ก ๆ ที่มี middleware พิมพ์ r.Method กับ r.URL.RequestURI() ทุก request
    ยิงเข้าไปสองแบบ: curl "localhost:PORT/a?ticket=SECRET" และ curl -X POST localhost:PORT/b -d '{"ticket":"SECRET"}'
    ดูว่าบรรทัดไหนมีคำว่า SECRET
  5. วัด Referer ด้วยตัวเอง: ให้ server เดิมเสิร์ฟหน้า HTML ที่ /page?ticket=SECRET ซึ่งข้างในมี <img src="/pixel.gif"> แล้วเพิ่ม r.Header.Get("Referer") เข้าไปใน log
    เปิดหน้านั้นด้วยเบราว์เซอร์จริง แล้วดูว่า Referer ของ /pixel.gif มี ticket ติดมาไหม
  6. ท้าทาย — วัดฝั่งข้ามเว็บ: เปิด server ตัวที่สองคนละพอร์ต แล้วให้หน้าเดิมโหลดรูปจากพอร์ตนั้นด้วย
    เทียบ Referer ที่สองฝั่งได้รับ แล้วอธิบายว่าทำไมไม่เหมือนกัน (คำใบ้: ค้นคำว่า strict-origin-when-cross-origin)
  7. ตั้ง Keycloak แล้วเดิน flow จริง: ตามสไลด์หน้า 39 · สร้าง realm + client แบบ confidential + user · แล้วเปิด authorization_endpoint ในเบราว์เซอร์พร้อม state ที่คุณตั้งเอง
    ดู URL ที่มันเด้งกลับมา แล้วนับว่ามีพารามิเตอร์กี่ตัว อะไรบ้าง
  8. ยืนยันว่า code ใช้ครั้งเดียว: เอา code จากข้อที่แล้วไปแลกที่ token_endpoint ด้วย curl สองครั้งติดกัน
    จดข้อความ error ของครั้งที่สองมาให้ตรงตัวอักษร

ข้อ 2 — สามสถานะของ TTL (ผลรันจริงบน Redis 8.10.1):

t=0s  TTL -> 3        # เลขบวก = เหลืออีกกี่วินาที
t=2s  TTL -> 1
t=4s  TTL -> -2       # ไม่มี key นี้แล้ว (หมดอายุไปแล้ว หรือไม่เคยมี)
t=4s  EXISTS -> 0

TTL forgot_ttl (SET โดยไม่ใส่ EX) -> -1   # key อยู่ แต่ไม่มีวันหมดอายุ

-1 คือข้อที่เป็นบั๊ก ถ้าเจอกับ auth_ticket: — แปลว่าลืมใส่ EX ตอน SET และ ticket ตัวนั้นจะอยู่ตลอดไป ทั้งที่มันควรตายใน 30 วินาที

ข้อ 3 — GETDEL เทียบกับ GET:

-- แบบที่ถูก --
GETDEL auth_ticket:a1b2c3 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GETDEL auth_ticket:a1b2c3 -> (ว่าง)
EXISTS auth_ticket:a1b2c3 -> 0

-- แบบที่มีบั๊ก --
GET auth_ticket:bug999 ครั้งที่ 1 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 2 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
GET auth_ticket:bug999 ครั้งที่ 3 -> {"accessToken":"eyJ...","refreshToken":"eyJ..."}
EXISTS auth_ticket:bug999         -> 1

ถ้าเขียนใน Go ด้วย go-redis ความต่างจะโผล่ที่ error: GetDel ครั้งที่สองได้ redis: nil ซึ่ง errors.Is(err, redis.Nil) เป็น true
อย่าลืมว่า redis.Nil แปลว่า "ไม่มี key" ไม่ใช่ "Redis พัง" — สอง error นี้ต้องแยกกันเสมอ ตามที่ บทเรียน 32 ย้ำไว้

💬 ข้อ 5 กับ 6 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ — เพราะการเห็น Referer="http://.../page?ticket=SECRET" โผล่ในเทอร์มินัลของตัวเอง มันคนละเรื่องกับการอ่านว่า "อย่าใส่ token ใน URL"
และถ้าคุณทำข้อ 6 จนเห็นว่าฝั่งข้ามเว็บได้แค่ชื่อโดเมน คุณจะเถียงเก่งขึ้นด้วย — เพราะรู้ว่าคำเตือนแบบเหมารวมข้อไหนล้าสมัยไปแล้ว และข้อไหนยังจริงอยู่
ผลรันทุกบรรทัดในหน้านี้วัดบน Redis 8.10.1 · Keycloak 26.7.2 · Go 1.26.5 darwin/arm64 · Google Chrome 151.0.7922.170 ถ้าของคุณออกมาไม่ตรง ส่งมาได้เลย

อ้างอิง