บทเรียน 50 · Day 9 · OAuth 2.0

OAuth 2.0 กับ Authorization Code Grant

~30 นาที · ต้องมี: บทเรียน 43 (ค่าที่อยู่ใน query string โผล่ใน access log) · บทเรียน 49 (จาก Basic Auth ถึง JWT)

บอกกันตรง ๆ ก่อน: สไลด์เรื่องนี้มีแค่สองหน้า
หน้า 35 เขียนไว้ประโยคเดียวว่า OAuth 2.0 คือ "framework สำหรับมอบสิทธิ์แบบจำกัดขอบเขต (delegated authorization)" · หน้า 36 เป็นรูปมือถือสองเครื่อง เครื่องซ้ายคือเว็บเราที่มีปุ่ม "Sign in with Google" มีลูกศรชี้ไปเครื่องขวาที่เป็นหน้าของ Google พร้อมหัวข้อว่า Authorization Code Grant
เท่านั้นจริง ๆ — ไม่มีขั้นตอน ไม่มีคำว่า code ไม่มีคำว่า state ไม่มีคำว่า scope อยู่ในสไลด์เลย
ส่วนที่เหลือของบทนี้ ผมขยายเพิ่มเองทั้งหมด และรันของจริงให้ดูทุกขั้น — ยก Keycloak ขึ้นมาเป็น Authorization Server แล้วเดิน Authorization Code Grant ครบรอบด้วย curl ตั้งแต่หน้า login จนได้ access_token
คำถามที่บทนี้อยากให้คุณตอบได้จริง ๆ มีข้อเดียว: ทำไมต้องมี "code" มาคั่นตรงกลาง ทำไมไม่ส่ง access_token กลับมาทาง URL เลยให้จบ ๆ ไป

🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน

Keycloak 26.7.2      # image quay.io/keycloak/keycloak:26.7 (tag นี้ตอนนี้ชี้ไป 26.7.2)
Docker client 29.6.2 / server 29.5.2   # ผ่าน colima ไม่ใช่ Docker Desktop
docker-compose 5.3.1 # เครื่องนี้ใช้ `docker-compose` (มีขีด) ไม่ใช่ `docker compose`
curl 8.13.0
go1.26.5 darwin/arm64 + github.com/gin-gonic/gin v1.12.0   # ใช้เฉพาะหัวข้อ 6 ตอนทำ callback server

Keycloak ในบทนี้รันด้วย start-dev ที่พอร์ต 8081 realm ชื่อ wongnok · client ชื่อ wongnok-web · user ชื่อ chef1
บทนี้ยังไม่สอนวิธีตั้ง Keycloak — เรื่องนั้นอยู่บทถัดไป บทนี้ใช้มันเป็นแค่ "ของจริงที่เอาไว้ยิง" เพื่อให้เห็นว่า OAuth ทำงานยังไงจริง ๆ
ทุกอย่างถูกลบทิ้งหมดแล้วหลังรันเสร็จ (docker-compose down -v)

1 · ปัญหาที่ OAuth เกิดมาเพื่อแก้

สมมติ Wongnok อยากมีฟีเจอร์ "ดึงรูปโปรไฟล์กับอีเมลจากบัญชี Google ของคุณมาใช้ตอนสมัคร"
ถ้าไม่มี OAuth วิธีเดียวที่ทำได้คือ ทำช่องสองช่องบนเว็บเรา แล้วขออีเมลกับรหัสผ่านของ Google จากผู้ใช้ตรง ๆ แล้วเราเอาไปล็อกอินแทนเขา
วิธีนี้มีชื่อเรียกในวงการว่า password anti-pattern และมันแย่ทุกทางพร้อมกันสี่ทาง

แย่ข้อ 1 — เราเห็นรหัสผ่านของเขา

ไม่ใช่ "อาจจะเห็น" แต่คือเห็นแน่นอน เพราะค่ามันวิ่งผ่านเซิร์ฟเวอร์เรา
ใน บทเรียน 49 เราพิสูจน์ไปแล้วกับ Basic Auth ว่ารหัสผ่านที่ส่งมาแบบนั้น ไม่ได้เข้ารหัส แค่ base64 ใครดัก header ได้ก็อ่านได้ทันทีโดยไม่ต้องมีกุญแจอะไรเลย:

Authorization: Basic c29tY2hhaTpwQHNzdzByZA==

# decode กลับได้ทันทีโดยไม่ต้องมี key:
somchai:p@ssw0rd

พอเราเห็นรหัสผ่าน เราก็กลายเป็นที่เก็บรหัสผ่านของ Google เพิ่มขึ้นอีกที่หนึ่ง ทั้งที่เราไม่ได้อยากรับผิดชอบเรื่องนี้เลย · แล้ววันที่ฐานข้อมูลเราหลุด รหัสผ่าน Google ของผู้ใช้ก็หลุดตามไปด้วย

แย่ข้อ 2 — จำกัดขอบเขตไม่ได้

เราอยากได้แค่ "ชื่อกับอีเมล" แต่รหัสผ่านที่ได้มาเปิดได้ทุกอย่าง — Gmail ทั้งกล่อง · Google Drive ทั้งไดรฟ์ · ปฏิทิน · YouTube · การตั้งค่าบัญชี
รหัสผ่านไม่มีช่องให้เขียนว่า "ใช้ได้แค่เรื่องนี้" มันเป็นสวิตช์เปิด-ปิดตัวเดียวที่คุมทุกอย่าง

แย่ข้อ 3 — ถอนสิทธิ์ทีหลังไม่ได้

วันหนึ่งผู้ใช้เลิกใช้ Wongnok แล้วอยากตัดสิทธิ์เรา — เขาทำยังไง?
ทางเดียวคือไปเปลี่ยนรหัสผ่าน Google ซึ่งแปลว่า...

แย่ข้อ 4 — เปลี่ยนรหัสผ่านทีเดียว พังหมดทุกที่

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

OAuth 2.0 คือคำตอบของทั้งสี่ข้อนี้ — แทนที่จะให้ผู้ใช้ส่งรหัสผ่านมาให้เรา เราให้เขาไปกรอกรหัสผ่านที่บ้านของเจ้าของบัญชีเอง (google.com / Keycloak ของเรา) แล้วเจ้าของบัญชีออกบัตรผ่านใบเล็ก ๆ (access token) กลับมาให้เราแทน
บัตรใบนั้น บอกได้ว่าใช้ทำอะไรได้บ้าง (scope) · มีวันหมดอายุ (บทนี้รันได้ 300 วินาที) · และยกเลิกใบเดียวได้โดยไม่แตะรหัสผ่าน
คำว่า delegated authorization ที่สไลด์หน้า 35 เขียนไว้ ก็แปลว่าเท่านี้เอง: "มอบสิทธิ์ให้คนอื่นทำแทน โดยไม่ต้องมอบกุญแจบ้าน"

🔑 เทียบให้เห็นภาพ — ช่างแอร์กับบัตรผู้มาติดต่อของคอนโด

ช่างแอร์โทรมาบอกว่าพรุ่งนี้จะเข้าไปล้างแอร์ห้องคุณ คุณมีสองทางเลือก

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

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

ทางที่ 1 คือ password anti-pattern ที่เพิ่งเล่าไปทั้งสี่ข้อ · ทางที่ 2 คือ OAuth 2.0
เคาน์เตอร์นิติบุคคล = Keycloak ที่ localhost:8081 · บัตรผู้มาติดต่อ = access_token · "ขึ้นได้เฉพาะชั้น 12" = scope · "เฉพาะวันนั้น" = อายุของบัตร ซึ่งบทนี้วัดได้จริงที่ expires_in = 300 วินาที
และจุดที่สำคัญที่สุด: ช่างไม่เคยได้จับกุญแจของคุณเลย เหมือนที่เว็บ Wongnok ไม่เคยเห็นรหัสผ่าน chef1234 ของ chef1 สักครั้ง

ที่ไม่เหมือนกัน: สองข้อ —
(1) ในคอนโด คนออกบัตรกับคนตรวจบัตรตอนขึ้นลิฟต์มักเป็น รปภ. ทีมเดียวกัน แต่ใน OAuth มันแยกเป็นคนละตัวชัดเจน คือ Keycloak เป็นคนออก ส่วน Wongnok API เป็นคนตรวจ (หัวข้อ 2 จะแยกให้ดูทีละตัว)
(2) บัตรผู้มาติดต่อต้องคืนที่เคาน์เตอร์ตอนกลับ แต่ access_token ไม่มีใครมาเก็บคืน — มันหมดอายุของมันเอง นี่คือเหตุผลที่อายุของมันต้องสั้น และเป็นที่มาของ refresh_token ในหัวข้อ 8

🗣 ศัพท์ที่เพิ่งเรียนไป — นิยามให้ชัดก่อนเดินต่อ

สี่คำนี้คือชื่อทางการของสิ่งที่อุปมาข้างบนเล่าไป — ใช้คำให้ตรง จะได้คำตอบที่ตรง

authentication (authn)
ตอบคำถาม "คุณคือใคร" — เช่นกรอก username/password แล้วระบบรู้ว่าคุณคือ chef1
authorization (authz)
ตอบคำถาม "คุณทำอะไรได้บ้าง" — เช่น chef1 แก้สูตรของตัวเองได้ แต่ลบของคนอื่นไม่ได้ · ประโยคที่เอาไปใช้ได้เลย: "อันนี้เป็นปัญหา authn หรือ authz" — ถามแบบนี้ senior จะรู้ทันทีว่าควรไปดูตรงไหน
delegated authorization
เจ้าของสิทธิ์มอบบางส่วนของสิทธิ์ตัวเองให้แอปอื่นทำแทนได้ ในขอบเขตที่กำหนด และถอนคืนได้ — นี่คือสิ่งที่ OAuth 2.0 เป็น (คือ "บัตรผู้มาติดต่อ" ในอุปมาข้างบน)
access token
"บัตรผ่าน" ที่แอปเอาไปยื่นให้ API เพื่อขอเข้าถึงข้อมูล · มีอายุสั้น · มีขอบเขตติดมาด้วย · พิมพ์ถาม AI ได้เลยว่า: "access token ใบนี้ควรเก็บไว้ที่ไหนฝั่ง server ไม่ให้หลุด"

⚠️ ระวังคำ: ชื่อเต็มของ OAuth 2.0 คือ The OAuth 2.0 Authorization Framework — มันเป็นเรื่อง authz ไม่ใช่ authn
ปุ่ม "Sign in with Google" ที่เห็นในสไลด์หน้า 36 นั้น จริง ๆ ใช้ OpenID Connect ซึ่งเป็นชั้นบาง ๆ ที่ต่อยอดจาก OAuth 2.0 อีกที (ตัวที่เพิ่ม id_token เข้ามา) — เดี๋ยวหัวข้อ 5 จะเห็น id_token โผล่มาจริง ๆ

2 · ตัวละครสี่ตัว

ก่อนอ่าน flow ต้องรู้จักตัวละครก่อน ไม่งั้นจะงงว่าใครส่งอะไรให้ใคร
OAuth 2.0 กำหนดบทบาทไว้ สี่บทบาท (RFC 6749 §1.1) และเกือบทุกคนที่งง OAuth งงเพราะแยกสี่ตัวนี้ไม่ออก:

บทบาทแปลว่าอะไรในบทนี้คือใครในกรณี "Sign in with Google"
Resource Owner เจ้าของข้อมูล = คนที่มีสิทธิ์อนุญาต user chef1 คุณ (เจ้าของบัญชี Google)
Client แอปที่อยากได้สิทธิ์ไปทำอะไรบางอย่างแทนเจ้าของ เว็บ Wongnok = client wongnok-web เว็บของเรา (เว็บที่มีปุ่มนั้น)
Authorization Server ตัวที่ถามผู้ใช้ว่าอนุญาตไหม แล้วออก token Keycloak ที่ localhost:8081 accounts.google.com
Resource Server API ที่ถือข้อมูลจริง และรับ token มาตรวจก่อนให้ของ Wongnok API (บทเรียน 33–42) Google People API / Gmail API

🧭 จุดที่คนสับสนมากที่สุดสองจุด
1. Client ไม่ใช่ browser — คำว่า client ในภาษา OAuth หมายถึงแอปของเรา (ทั้งฝั่ง frontend และ backend รวมกันเป็นตัวเดียว) ไม่ได้แปลว่า "ฝั่งลูกค้า" แบบที่ใช้ในคำว่า client-side
2. Authorization Server กับ Resource Server เป็นคนละตัว — ตัวหนึ่งออกบัตร อีกตัวตรวจบัตร · ในโปรเจกต์เล็ก ๆ อาจรันอยู่ในเครื่องเดียวกันได้ แต่หน้าที่คนละอย่าง และในบทนี้เป็นคนละตัวจริง ๆ (Keycloak ออก token · Wongnok API ตรวจ token)

🔤 ศัพท์ที่จะใช้ทั้งบท: client มีสองพันธุ์
· confidential client = client ที่มี backend เป็นของตัวเอง จึงเก็บ client_secret ไว้ได้จริง โดยผู้ใช้มองไม่เห็น · wongnok-web ในบทนี้เป็นแบบนี้
· public client = SPA กับ mobile app ที่โค้ดทั้งก้อนอยู่ในมือผู้ใช้ — ฝัง secret ลงไปเมื่อไหร่ก็โดนแกะเมื่อนั้น จึงไม่มี secret เลย
ตรงนี้สำคัญมาก เพราะเหตุผลหลักของบทนี้ที่ว่า "code เปล่า ๆ ใช้ไม่ได้เพราะยังต้องมี client_secret มาแลก" ใช้กับ public client ไม่ได้ — ด่านที่หายไปนั้นถูกแทนด้วย PKCE (หัวข้อ 4) นั่นเอง

3 · Authorization Code Grant ทีละขั้น

ชื่อยาว ๆ นี้แปลได้ว่า "วิธีขอสิทธิ์แบบเอารหัสชั่วคราวมาแลก" — grant แปลว่า "วิธีที่ client ใช้ขอ token" · authorization code คือรหัสชั่วคราวที่ Authorization Server ส่งกลับมาแทน token (ย้ำว่าไม่ใช่รหัสผ่าน และไม่ใช่ token — เป็นแค่ตั๋วไว้ไปแลก)
นี่คือภาพรวมทั้งรอบ ตัวเลขในวงเล็บคือค่าจริงที่ผมรันได้ (ย่อบางส่วนให้พอดีจอ):

[1] ผู้ใช้กดปุ่ม "Sign in with Google" ───► เว็บเรา (Client) │ สุ่ม state = xyz-997d202f5a9a1f92 │ จำไว้ใน session ของตัวเอง ▼ ╔══════════════ FRONT CHANNEL — เดินผ่าน browser ของผู้ใช้ ══════════════╗ [2] ║ GET http://localhost:8081/realms/wongnok/protocol/openid-connect/auth ║ ║ ?response_type=code ║ ║ &client_id=wongnok-web ║ ║ &redirect_uri=http://localhost:3000/callback ║ ║ &scope=openid profile email ║ ║ &state=xyz-997d202f5a9a1f92 ║ ╚═══════════════════════════════════════════════════════════════════════╝ ▼ [3] Keycloak ส่งหน้า login กลับมา (HTTP 200 · 6837 byte) └─► รหัสผ่านถูกกรอกที่นี่ ที่บ้านของ Keycloak เว็บเราไม่เคยเห็นมันเลยแม้แต่ครั้งเดียว [4] ผู้ใช้กรอก chef1 / chef1234 แล้วกด Sign In [5] (ถ้าเปิด consent) Keycloak ถาม "Do you grant these access privileges?" → Yes / No ▼ ╔══════════════ FRONT CHANNEL — เดินผ่าน browser อีกรอบ ═══════════════╗ [6] ║ HTTP/1.1 302 Found ║ ║ Location: http://localhost:3000/callback ║ ║ ?state=xyz-997d202f5a9a1f92 ║ ║ &code=9016d08d-….y92Kwwzl….dc8e45a5-… ║ ║ ▲ ║ ║ └─ ได้แค่ "code" เท่านั้น ไม่มี access_token ตรงนี้ ║ ╚═══════════════════════════════════════════════════════════════════════╝ ▼ [7] เว็บเรา (ฝั่ง server) ตรวจก่อน: state ที่กลับมา == state ที่สุ่มไว้ตอน [1] ไหม? ถ้าไม่ตรง → ทิ้งทันที ห้ามเดินต่อ ▼ ╔══════════════ BACK CHANNEL — server คุยกับ server ตรง ๆ ══════════════╗ [8] ║ POST http://localhost:8081/realms/wongnok/protocol/openid-connect/token║ ║ grant_type=authorization_code ║ ║ code=9016d08d-…. ║ ║ client_id=wongnok-web ║ ║ client_secret=web-secret-123 ◄── ค่านี้ไม่เคยผ่าน browser เลย ║ ║ redirect_uri=http://localhost:3000/callback ║ ╚═══════════════════════════════════════════════════════════════════════╝ ▼ [9] HTTP 200 { "access_token": "eyJ…" (1377 byte), "expires_in": 300, "refresh_token": "eyJ…", "id_token": "eyJ…", "token_type": "Bearer", "scope": "openid email profile" } ▼ [10] เว็บเรายิง API: GET /recipes Authorization: Bearer eyJhbGciOiJSUzI1NiI…
🛵 เทียบให้เห็นภาพ — ใบปะหน้าถุงอาหาร กับ เงินที่ร้านเคลียร์กับแอปเอง

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

ใบปะหน้า = front channel — คือ URL ในขั้น [2] และ [6] ที่มี client_id · redirect_uri · state · code
ร้านเคลียร์กับแอปเอง = back channel — คือขั้น [8] ที่ POST client_secret ไปแลก access_token
แล้วทำไมถึงกล้าเขียนเลขออร์เดอร์บนใบปะหน้า? เพราะเลขออร์เดอร์เปล่า ๆ สั่งอาหารเพิ่มไม่ได้ — เหมือน code ที่หลุดออกไปแล้วยังแลกอะไรไม่ได้ ถ้าไม่มี client_secret มาด้วย (หัวข้อ 4 มีผลรันจริงทั้งสี่ด่าน)

ที่ไม่เหมือนกัน: ไรเดอร์เป็นคนนอกที่เราไม่รู้จัก แต่ browser เป็นของผู้ใช้เอง — เราไม่ได้กลัวว่าผู้ใช้จะแอบอ่าน token ของตัวเอง
สิ่งที่กลัวจริง ๆ คือ URL ที่ผ่าน front channel ถูกจดเก็บไว้อัตโนมัติหลายที่ (access log ของเว็บเราเอง · ประวัติ browser ของผู้ใช้ · header Referer) แล้วคนอื่นมาเปิดอ่านทีหลัง — หัวข้อ 4 มีบรรทัดจริงจาก gin-access.log ให้ดูกับตา

🗣 ศัพท์ที่เพิ่งเรียนไป — สองคำที่ต้องแยกให้ออก

ไม่งั้นหัวข้อ 4 จะไม่เข้าใจ · และสองคำนี้ใช้ได้กับทุกระบบ ไม่ใช่แค่ OAuth

front channel
ข้อมูลเดินทางผ่าน browser ของผู้ใช้ — คือ URL, redirect, query string, fragment · ใครนั่งอยู่หน้าเครื่องก็เห็น · เครื่องมือทุกตัวที่อยู่ระหว่างทางก็จดได้ (คือ "ใบปะหน้าถุงอาหาร" ในอุปมาข้างบน) · ประโยคที่เอาไปใช้ได้เลย: "ค่านี้เดินทาง front channel รึเปล่า ถ้าใช่มันจะไปโผล่ใน log"
back channel
เซิร์ฟเวอร์ของเรายิงตรงไปหาเซิร์ฟเวอร์ปลายทาง — browser ไม่เกี่ยวเลย ไม่มีอะไรโผล่บนจอผู้ใช้ และค่าลับอย่าง client_secret อยู่ในนี้ได้ · พิมพ์ถาม AI ได้เลยว่า: "ขั้นตอนนี้ควรทำที่ back channel หรือทำที่ browser ได้"

ทั้งเรื่องของ Authorization Code Grant ก็คือประโยคเดียวนี้: ของที่ไม่ลับ (code) เดินทาง front channel · ของที่ลับ (access_token, client_secret) เดินทาง back channel เท่านั้น

4 · คำถามใหญ่ของบทนี้ — ทำไมต้องมี code คั่นกลาง

มาถึงจุดที่ผมอยากให้คิดจริง ๆ ก่อนอ่านต่อ
ดู flow ข้างบนแล้วมันดูอ้อมมาก: ขั้นที่ [6] Keycloak รู้แล้วว่าผู้ใช้เป็นใครและอนุญาตแล้ว มันจะส่ง access_token กลับมาใน URL เลยก็ได้ จบใน 6 ขั้น ไม่ต้องมีขั้น [8] ให้เมื่อยเลย

ทายก่อนอ่านเฉลย — สมมติ Keycloak ส่ง token กลับมาทาง URL ตรง ๆ แบบนี้แทน:

HTTP/1.1 302 Found
Location: http://localhost:3000/callback?access_token=eyJhbGciOiJSUzI1NiI…

ผู้ใช้ก็ล็อกอินสำเร็จเหมือนกัน เว็บเราก็ได้ token เหมือนกัน สั้นกว่าด้วย
แล้วมันเสียหายตรงไหน? ลองนึกถึงสามที่นี้: ไฟล์ log ของเว็บเราเอง · ประวัติ browser ของผู้ใช้ · header Referer
และคำถามที่สอง: ถ้าเปลี่ยนเป็นส่ง code แทน แล้ว code ที่โผล่ใน log เหมือนกันเป๊ะ ทำไมถึงไม่เสียหายเท่ากัน?

ข้อแรก: ค่าที่อยู่ใน URL ไม่ได้หายไปไหน มันถูกจดไว้
ผมยกเว็บ callback จริงขึ้นมาด้วย Gin (gin.Default() ซึ่งมี gin.Logger() ติดมาให้) ที่ 127.0.0.1:3000 แล้วเดิน flow จริงจน Keycloak redirect กลับมา นี่คือบรรทัดที่โผล่ในไฟล์ log ของเว็บเราเอง:

$ cat gin-access.log
[GIN] 2026/08/22 - 11:38:44 | 200 |  2.458µs |       127.0.0.1 | GET      "/callback
  ?state=st-3c715c27cc5d
  &session_state=lG20JnTV9jXGYuYSn9cTF4hw
  &iss=http%3A%2F%2Flocalhost%3A8081%2Frealms%2Fwongnok
  &code=76a08add-6f8c-79b1-af2a-39097022f3d5.lG20JnTV9jXGYuYSn9cTF4hw.dc8e45a5-…"

(ของจริง gin พิมพ์เป็นบรรทัดเดียวยาว ๆ ตรงนี้ผมตัดขึ้นบรรทัดใหม่ให้พออ่านบนจอ · ช่องว่างเยอะ ๆ หน้า 127.0.0.1 กับหลัง GET ไม่ใช่พิมพ์ผิด — gin จัดคอลัมน์ clientIP กว้าง 15 และ method กว้าง 7 ให้เอง)

query string ทั้งก้อนถูกเขียนลงไฟล์แบบอ่านได้ตรง ๆ — นี่คือเรื่องเดียวกับที่เราพิสูจน์ไปแล้วใน บทเรียน 43 ตอนที่ POST /createUser?name=Alice&password=123456 ทำให้รหัสผ่านไปนอนอยู่ใน access log
ถ้าตรงนั้นเป็น access_token แทนที่จะเป็น code ก็คือ token ของผู้ใช้ถูกเขียนลง log ทุกครั้งที่มีคนล็อกอิน · แล้ว log ก็ถูกส่งต่อไปที่ระบบเก็บ log กลาง ถูกสำรอง ถูกเปิดดูตอน debug — กระจายไปเรื่อย ๆ

และ log ของเราเป็นแค่ที่แรก · RFC 9700 (Best Current Practice for OAuth 2.0 Security, มกราคม 2025) §4.3.2 ชื่อหัวข้อว่า "Access Token in Browser History" เขียนไว้ตรง ๆ ว่า URL แบบ client.example/redirection_endpoint#access_token=abcdef "may also end up in the browser history" และสรุปเป็นข้อบังคับว่า "Clients MUST NOT pass access tokens in a URI query parameter in the way described in Section 2.3 of [RFC6750]."

และที่สาม: header Referer
Referer คือ header ที่ browser แนบ URL ของหน้าที่กำลังอยู่ ไปกับ request ถัดไปให้เว็บปลายทางเห็น (สะกดตกตัว r มาตั้งแต่ปี 1996 แล้วไม่มีใครกล้าแก้)
แปลว่าถ้าหน้า /callback ของเรามี token ติดอยู่ใน URL แล้วหน้านั้นโหลดรูป โหลดสคริปต์ หรือมีลิงก์ให้ผู้ใช้กดไปโดเมนอื่น URL ที่มี token ก็เดินทางไปกับ Referer ให้โดเมนนั้นอ่านด้วย — เจ้าของเว็บปลายทางไม่ต้องแฮ็กอะไรเลย แค่เปิด access log ของตัวเองก็เห็น

ข้อสอง: ทำไม code โผล่ใน log แล้วไม่เจ็บเท่ากัน
เพราะ code เปล่า ๆ ทำอะไรไม่ได้เลย มันต้องผ่านด่านสี่ด่านพร้อมกันถึงจะแลกเป็น token ได้ และผมทดสอบทั้งสี่ด่านมาแล้ว (ผลจริงอยู่ในหัวข้อ 5):

ด่านถ้าไม่ผ่าน ได้อะไร (ผลรันจริง)
ต้องมี client_secret ที่อยู่ฝั่ง server เท่านั้น HTTP 401 unauthorized_client
ใช้ได้ครั้งเดียว ครั้งที่สอง HTTP 400 invalid_grant · "Code not valid"
อายุ 60 วินาที (ค่า default ของ Keycloak realm นี้) รอ 65 วิแล้วยิง HTTP 400 invalid_grant · "Code not valid"
redirect_uri ต้องตรงกับตอนขอเป๊ะ ๆ HTTP 400 invalid_grant · "Incorrect redirect_uri"

สรุปเป็นประโยคเดียว: access_token คือ "กุญแจ" ที่ใครถือก็ใช้ได้เลย แต่ code คือ "ใบรับของ" ที่ต้องเอาไปยื่นพร้อมบัตรพนักงาน ภายในหนึ่งนาที และยื่นได้ครั้งเดียว
ของสองอย่างนี้จึงเดินทางคนละช่องทางกันอย่างถูกต้องแล้ว

⚠️ ข้อควรระวัง — "code หลุดแล้วไม่เป็นไร" ก็พูดเกินไปเหมือนกัน

RFC 9700 §4.3.1 ชื่อหัวข้อ "Authorization Code in Browser History" เตือนว่า attacker ที่เข้าถึงเครื่องได้อาจหยิบ code ไป replay ได้ ถ้ายังไม่มีใครใช้มัน
เพราะฉะนั้นข้อสรุปที่ถูกคือ: code ปลอดภัยกว่ามาก ไม่ใช่ ปลอดภัยสมบูรณ์
ส่วนที่เหลือถูกอุดด้วย PKCE (อ่านว่า "พิกซี่") ซึ่งบังคับให้คนที่จะแลก code ต้องพิสูจน์ว่าเป็นคนเดียวกับที่ขอ code ตอนแรก — OAuth 2.1 บังคับใช้ PKCE เป็นค่าเริ่มต้น กับ authorization code grant ทุกชนิดของ client ไม่ใช่แค่ mobile app
(draft-ietf-oauth-v2-1-15 §4.1.1 เขียนว่า "Clients MUST use code_challenge and code_verifier and authorization servers MUST enforce their use except under the conditions described in Section 7.5.1" · ข้อยกเว้นเดียวใน §7.5.1.1 คือ client ที่เป็น confidential และ AS มั่นใจว่า client ใช้ OIDC nonce ถูกต้อง — และถึงตรงนั้นสเปกก็ยังเขียนว่า "still RECOMMENDED" ให้ใช้ PKCE อยู่ดี)
ในการรันของบทนี้ผมไม่ได้ส่ง PKCE ไปด้วย และ Keycloak 26.7.2 ก็ยอมออก token ให้ — แปลว่ามันไม่ได้บังคับให้ตาม default · ถ้าจะใช้จริงต้องไปเปิดเอง (เรื่องนี้อยู่นอกขอบเขตบทนี้)

🚧 เทียบให้เห็นภาพ — สมุดบันทึกรถเข้าออกที่ป้อมยาม

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

URL ที่วิ่งผ่าน front channel ก็คือช่องหมายเหตุนั้น
· ใส่ access_token ลงไป = เขียนรหัสตู้เซฟให้ยามจด — ใครเปิดสมุดก็เอาไปใช้ได้ทันที
· ใส่ code ลงไป = เขียนแค่ "เลขที่นัดหมาย" ที่ต้องเอาไปยืนยันตัวที่เคาน์เตอร์อีกด่าน ภายในหนึ่งนาที และยื่นได้ครั้งเดียว ถึงจะได้ของ
สมุดเล่มนั้นของจริงอยู่ในเฉลยข้างบนแล้ว — บรรทัดใน gin-access.log ที่มี ?state=…&code=… ติดมาครบทั้งก้อน

ที่ไม่เหมือนกัน: สองข้อ —
(1) ยามจดสมุดเล่มเดียวที่ป้อม แต่ URL ถูกจดหลายที่พร้อมกันโดยที่เราคุมไม่ได้: access log ฝั่งเรา · ประวัติ browser ที่อยู่ในเครื่องผู้ใช้ · และ header Referer ที่ browser ส่งต่อให้โดเมนอื่นเอง โดยไม่มีใครต้องไปขอ
(2) อย่าอ่านอุปมานี้ว่า "เลขที่นัดหมายหลุดแล้วไม่เป็นไรเลย" — กล่องเตือนข้างบนอ้าง RFC 9700 §4.3.1 ไว้แล้วว่า code ที่ยังไม่มีใครใช้ ถูกหยิบไป replay ได้อยู่ดี · ที่ถูกคือ เจ็บน้อยกว่ามาก ไม่ใช่ไม่เจ็บเลย

🗣 ศัพท์ที่เพิ่งเรียนไป

สี่คำนี้จะโผล่ทุกครั้งที่คุยเรื่อง OAuth กับ senior หรือพิมพ์ถาม AI — ใช้คำให้ตรง จะได้คำตอบที่ตรง

authorization code
"ใบรับของ" ที่ Authorization Server ส่งกลับมาแทน token · ใช้ได้ครั้งเดียว อายุสั้น (realm ในบทนี้ตั้งไว้ 60 วินาที) และต้องมี client_secret มาแลก · ประโยคที่เอาไปใช้ได้เลย: "ตอนนี้เราแลก code เป็น token ที่ฝั่ง server หรือยิงจาก frontend" — สื่อกว่าถามว่า "ตรงนี้ทำถูกไหม"
replay
การเอาค่าที่ดักได้มายิงซ้ำเพื่อสวมรอย · เหตุผลที่ code ถูกออกแบบให้ใช้ได้ครั้งเดียวก็เพื่อกันเรื่องนี้ · พิมพ์ถาม AI ได้เลยว่า: "authorization code replay ป้องกันยังไงถ้ายังไม่ได้เปิด PKCE"
PKCE (อ่านว่า "พิกซี่")
ด่านเพิ่มที่บังคับให้คนที่มาแลก code ต้องพิสูจน์ว่าเป็นคนเดียวกับที่ขอ code ตอนแรก · มีไว้แทนด่าน client_secret ในแอปที่เก็บ secret ไม่ได้ (SPA / mobile) · ประโยคที่เอาไปใช้ได้เลย: "client ตัวนี้เปิด PKCE ไว้รึยัง"
BCP กับ Internet-Draft
ป้ายบอกสถานะของเอกสารที่เราเอามาอ้าง · BCP (เช่น RFC 9700 = BCP 240) คือ "แนวปฏิบัติที่ดีที่สุด ณ ตอนนี้" ที่ประกาศเป็นทางการแล้ว · Internet-Draft (เช่น draft-ietf-oauth-v2-1-15) คือฉบับร่างที่เลขฉบับยังขยับได้ · ประโยคที่เอาไปใช้ได้เลย: "อันนี้อ้างจาก BCP หรือยังเป็น draft อยู่"

5 · เดินของจริงทีละคำสั่ง

ตรงนี้คือของจริงทั้งหมด — ผมใช้ curl แสดงบทเป็น browser (เก็บ cookie ไว้ในไฟล์ แล้ว POST ฟอร์ม login เอง) เพื่อให้เห็น HTTP ทุกบรรทัดที่ปกติ browser ซ่อนไว้

ขั้น [2] — พา browser ไปหา Authorization Server

$ curl -s -c cookies.txt -o login.html -w "HTTP %{http_code}  content-type=%{content_type}  bytes=%{size_download}\n" \
  "http://localhost:8081/realms/wongnok/protocol/openid-connect/auth\
?response_type=code\
&client_id=wongnok-web\
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback\
&scope=openid%20profile%20email\
&state=xyz-997d202f5a9a1f92"

HTTP 200  content-type=text/html;charset=utf-8  bytes=6837

ได้ หน้า HTML ของ Keycloak กลับมา 6837 byte — คือหน้า login นั่นเอง
สังเกตพารามิเตอร์ห้าตัวที่ส่งไป มันคือ "ใบคำขอ" ที่บอกครบว่า ใครขอ · ขออะไร · ให้ส่งกลับที่ไหน:

พารามิเตอร์ค่าที่ส่งจริงแปลว่า
response_typecode "ขอ code นะ ไม่ใช่ token" — ค่านี้แหละที่เลือก grant type
client_idwongnok-web เว็บไหนเป็นคนขอ · ไม่ใช่ความลับ โผล่ใน URL ได้
redirect_urihttp://localhost:3000/callback ให้ส่ง code กลับมาที่ไหน · ต้องลงทะเบียนไว้ก่อนที่ Authorization Server
scopeopenid profile email ขอสิทธิ์แค่ไหน — หัวข้อ 7 ว่าด้วยเรื่องนี้
statexyz-997d202f5a9a1f92 ค่าสุ่มที่ client สร้างเอง — หัวข้อ 6 ว่าด้วยเรื่องนี้

ขั้น [4] — ผู้ใช้กรอกรหัสผ่านที่บ้านของ Keycloak

ในหน้า HTML ที่ได้มามี <form id="kc-form-login" action="…"> อยู่ ผมดึง action ออกมาแล้ว POST ไปตรง ๆ เหมือนที่ browser ทำเวลาผู้ใช้กดปุ่ม Sign In:

# ดึง action ของฟอร์มออกมาก่อน (นี่คือสิ่งที่ browser ทำให้เราตอนกดปุ่ม)
$ ACTION=$(grep -o 'id="kc-form-login"[^>]*action="[^"]*"' login.html \
    | sed -n 's/.*action="\([^"]*\)".*/\1/p' | sed 's/&amp;/\&/g')

$ curl -s -b cookies.txt -c cookies.txt -o /dev/null -D headers.txt -X POST "$ACTION" \
    --data-urlencode "username=chef1" \
    --data-urlencode "password=chef1234"

$ grep -i '^HTTP/\|^location:' headers.txt
HTTP/1.1 302 Found
Location: http://localhost:3000/callback
    ?state=xyz-997d202f5a9a1f92
    &session_state=y92Kwwzlv0CgQdv64G400Txv
    &iss=http%3A%2F%2Flocalhost%3A8081%2Frealms%2Fwongnok
    &code=9016d08d-9cd2-a75a-7e34-58bbba3815d6.y92Kwwzlv0CgQdv64G400Txv.dc8e45a5-9139-48de-bbdf-a0398af573bb

รหัสผ่าน chef1234 ถูกส่งไปที่ localhost:8081 ซึ่งคือ Keycloak — ไม่ได้ผ่านเว็บเราที่ localhost:3000 เลยแม้แต่นิดเดียว นี่คือหัวใจของ delegated authorization
และสิ่งที่กลับมาคือ code ยาว 98 ตัวอักษร ไม่มีคำว่า access_token อยู่ในบรรทัดนี้เลย

📌 เกร็ดจากผลจริง — Keycloak แถม iss=… กลับมาด้วย ทั้งที่เราไม่ได้ขอ นี่คือพารามิเตอร์ตาม RFC 9207 ที่บอกว่า "คำตอบนี้ออกโดย Authorization Server ตัวไหน" ไว้กันmix-up attack (เคสที่ client คุยกับ Authorization Server หลายเจ้า แล้วโดนหลอกให้เอา code ของเจ้าหนึ่ง ไปยื่นให้อีกเจ้าหนึ่ง) · ส่วน session_state เป็นของ OpenID Connect ไม่ใช่ OAuth แกน

ขั้น [8] — เอา code ไปแลก token ทาง back channel

# ดึง code ออกจากบรรทัด Location ที่เพิ่งได้มา
$ CODE=$(grep -i '^location:' headers.txt \
    | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p' | tr -d '\r')

$ curl -s -X POST "http://localhost:8081/realms/wongnok/protocol/openid-connect/token" \
    -d grant_type=authorization_code \
    -d client_id=wongnok-web \
    -d client_secret=web-secret-123 \
    -d redirect_uri=http://localhost:3000/callback \
    --data-urlencode "code=$CODE"

HTTP 200
access_token       = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi... [len 1377]
expires_in         = 300
refresh_expires_in = 1800
refresh_token      = eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwi... [len 694]
token_type         = Bearer
id_token           = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi... [len 1067]
not-before-policy  = 0
session_state      = y92Kwwzlv0CgQdv64G400Txv
scope              = openid email profile

สังเกตสามอย่าง:
· เป็น POST และค่าทุกตัวอยู่ใน body ไม่ใช่ query string — code กับ client_secret จึงไม่ไปโผล่ใน access log ของใครทั้งนั้น
· expires_in = 300 คือ access token อยู่ได้ 5 นาที (ค่า default ของ Keycloak 26.7 · ไม่ใช่ 1 ชั่วโมงตามที่หลายคนจำ)
· ได้ id_token แถมมาด้วยเพราะเราขอ scope=openid — ตัวนี้เป็นของ OpenID Connect ไม่ใช่ OAuth แกน

นี่คือ payload ของ access_token ที่ได้จริง (แกะ base64 ส่วนกลางออกมา):

{
  "iss": "http://localhost:8081/realms/wongnok",
  "aud": "account",
  "sub": "7d93f608-bcc4-4d06-b082-8b0553a4777a",
  "typ": "Bearer",
  "azp": "wongnok-web",
  "scope": "openid email profile",
  "preferred_username": "chef1",
  "email": "chef1@wongnok.dev"
}
// header alg = RS256 · exp - iat = 300 วินาที
⚠️ aud ไม่ใช่ client_id — จุดนี้พลาดกันเยอะ

"aud": "account" ไม่ใช่ "wongnok-web"
ถ้า API ฝั่งเราเขียนตรวจว่า aud == "wongnok-web" จะไม่ผ่านทุกใบ
ตัวที่บอกว่า token ใบนี้ออกให้ client ไหนคือ azp (authorized party) ต่างหาก
ถ้าอยากให้ aud เป็นชื่อ client จริง ๆ ต้องไปเพิ่ม audience mapper ที่ Keycloak เอง

สี่ด่านที่ทำให้ code ที่หลุดไปไร้ประโยชน์

ทั้งสี่เคสนี้ผมยิงจริงทีละเคส ใช้ code ที่เพิ่งได้มาสด ๆ ทุกครั้ง:

# A) ไม่ส่ง client_secret เลย (เหมือน attacker ที่ขโมย code จาก log ไป)
{"error":"unauthorized_client","error_description":"Invalid client or Invalid client credentials"}
HTTP 401

# B) ส่ง client_secret ที่เดามั่ว
{"error":"unauthorized_client","error_description":"Invalid client or Invalid client credentials"}
HTTP 401

# C) redirect_uri ไม่ตรงกับตอนขอ (ขอไว้ /callback แต่มาแลกด้วย /evil)
{"error":"invalid_grant","error_description":"Incorrect redirect_uri"}
HTTP 400

# D) เอา code เดิมมาแลกซ้ำเป็นครั้งที่สอง
{"error":"invalid_grant","error_description":"Code not valid"}
HTTP 400

ฝั่ง Keycloak ก็จดเหตุผลไว้ในล็อกของมันด้วย บรรทัดนี้ของเคส C อธิบายชัดที่สุด:

type="CODE_TO_TOKEN_ERROR" realmName="wongnok" clientId="wongnok-web"
  error="invalid_redirect_uri"
  reason="Parameter 'redirect_uri' did not match originally saved redirect URI
          used in initial OIDC request. Saved redirectUri: http://localhost:3000/callback,
          redirectUri parameter: http://localhost:3000/evil"
  grant_type="authorization_code" client_auth_method="client-secret"

แล้วเรื่อง "หมดอายุใน 60 วินาที" ผมก็วัดจริง — ค่าที่ตั้งอยู่ใน realm อ่านได้จาก admin API:

accessCodeLifespan  = 60   วินาที   # อายุของ authorization code
accessTokenLifespan = 300  วินาที   # อายุของ access token

# ขอ code มาแล้วนั่งรอ 65 วินาที ค่อยเอาไปแลก:
{"error":"invalid_grant","error_description":"Code not valid"}
HTTP 400

🔍 สังเกตข้อความ error ให้ดี — เคส "ใช้ซ้ำ" กับเคส "หมดอายุ" ได้ข้อความเหมือนกันเป๊ะคือ Code not valid
เวลา debug จริงคุณจะแยกสองเคสนี้จาก error ไม่ออก ต้องไปดูล็อกฝั่ง Keycloak เอา
อีกอย่างที่ควรจำ: RFC 6749 §4.1.2 แนะนำอายุ code ไว้ที่ ไม่เกิน 10 นาที และเขียนว่า "The client MUST NOT use the authorization code more than once" — Keycloak ตั้ง 60 วินาทีให้ ซึ่งเข้มกว่าที่ RFC แนะนำ

บทพิสูจน์ว่า token ไม่เคยผ่าน browser จริง ๆ

หลังรันครบทุกเคส ผม grep หาคำว่า access_token ในทุกอย่างที่ browser มองเห็น:

Location header ทั้งหมดที่ Keycloak ส่งกลับมา = 3 อัน
  บรรทัดที่มีคำว่า access_token = 0

access log ของเว็บเราเอง (gin-access.log)
  บรรทัดที่มีคำว่า access_token = 0
  บรรทัดที่มีคำว่า code=        = 2

ศูนย์ — ไม่ใช่ "น้อย" แต่คือไม่มีเลย
ทุกใบของ access_token เกิดขึ้นในขั้น [8] ซึ่งเป็น server-to-server ล้วน ๆ

6 · state — พารามิเตอร์ที่ดูไร้สาระที่สุด แต่ตัดออกไม่ได้

state คือค่าสุ่มที่ client สร้างเอง ส่งไปกับใบคำขอในขั้น [2] แล้ว Authorization Server จะส่งกลับมาเหมือนเดิมเป๊ะ ๆ ในขั้น [6]
ฟังดูเหมือนของไร้ประโยชน์ — ส่งไปแล้วได้กลับมาเท่าเดิม จะมีไว้ทำไม

ทายก่อนอ่านเฉลย — ผมยิงสองการทดลองนี้กับ Keycloak ของจริง

# การทดลอง 1: ส่ง state ที่ไม่ได้สุ่มเลย เดาง่ายสุด ๆ
&state=AAAA-not-random-1234

# การทดลอง 2: ไม่ส่ง state เข้าไปเลยสักตัว
(ตัดพารามิเตอร์ state ออกจาก URL ทั้งอัน)

ทายว่า Keycloak จะทำยังไงกับสองเคสนี้
· เคส 1 มันจะปฏิเสธไหมว่า "state นี้เดาง่ายเกินไป"?
· เคส 2 มันจะบอกไหมว่า "คุณลืม state"?
และคำถามที่สำคัญที่สุด: ถ้ามันไม่ว่าอะไรเลยทั้งสองเคส แล้วใครกันแน่ที่มีหน้าที่ตรวจ state?

# การทดลอง 1 — ส่ง state ที่เดาง่ายมาก
Location = http://localhost:3000/callback?state=AAAA-not-random-1234&session_state=…
ได้ code มาด้วยไหม = True

# การทดลอง 2 — ไม่ส่ง state เลย
HTTP/1.1 302 Found
Location: http://localhost:3000/callback
    ?session_state=8yUy2W06g5Hh6A2FeXqObSrQ
    &iss=http%3A%2F%2Flocalhost%3A8081%2Frealms%2Fwongnok
    &code=0d9e8ae3-2129-e1be-9fcb-f5f70dad4ca7.…     ← ได้ code ปกติ ไม่มีคำเตือนอะไรเลย

Authorization Server ไม่ตรวจอะไรเลยทั้งสองเคส — เคส 1 มันคืนค่าที่เราส่งไปกลับมาเหมือนกระจกสะท้อน · เคส 2 มันก็ออก code ให้เฉย ๆ
แปลว่า state เป็นหน้าที่ของ client ล้วน ๆ ทั้งการสุ่ม ทั้งการเก็บ และทั้งการเอามาเทียบ — ไม่มีใครทำแทนให้ และไม่มีใครฟ้องถ้าคุณลืม

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

แล้ว state กันอะไร

กัน CSRF (Cross-Site Request Forgery) — การโจมตีที่ attacker หลอกให้ browser ของเหยื่อยิง request ที่ attacker เป็นคนแต่งขึ้น
ในบริบทของ OAuth มันมีหน้าตาแบบนี้ ซึ่งกลับหัวจากที่คนส่วนใหญ่คิด:

1. attacker ล็อกอิน Wongnok ผ่าน Keycloak ด้วยบัญชีของ attacker เอง → ได้ code ของตัวเองมาหนึ่งใบ แล้วไม่ใช้มัน เก็บไว้เฉย ๆ 2. attacker ทำลิงก์ขึ้นมาหนึ่งอัน: http://wongnok.example/callback?code=<code ของ attacker> แล้วส่งให้เหยื่อ (แปะในเว็บบอร์ด · ฝังเป็นรูป · ส่งไลน์) 3. เหยื่อกดลิงก์ → browser ของเหยื่อยิงเข้า callback ของเรา เว็บเราเห็น code → เอาไปแลก token ตามปกติ → ได้ token ของ attacker → ผูก token ของ attacker เข้ากับ session ของเหยื่อ 4. ตอนนี้เหยื่อกำลังใช้ Wongnok อยู่ แต่ผูกกับบัญชี Google ของ attacker เหยื่ออัปโหลดสูตรอาหาร / บันทึกที่อยู่ / ผูกบัตร → ทุกอย่างไปเข้าบัญชีของ attacker หมด

RFC 6749 §10.12 อธิบายเรื่องนี้ไว้ตรง ๆ ว่า CSRF ที่ redirection URI ทำให้ attacker "inject its own authorization code or access token" ได้ และสรุปเป็นข้อบังคับว่า "The client MUST implement CSRF protection for its redirection URI" โดยแนะนำให้ทำผ่านพารามิเตอร์ state

📦 เทียบให้เห็นภาพ — พัสดุที่มาถึงหน้าบ้านทั้งที่เราไม่ได้สั่ง

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

state คือเลขในสมุดเล่มนั้น และงานของมันมีสามจังหวะพอดีกับตารางข้างล่าง: จดเลขตอนสั่ง (สุ่มค่า) · เก็บสมุดไว้กับตัว (เก็บใน session) · เปิดเทียบตอนของมาถึง (เทียบตอน callback)
ขาดจังหวะไหนไปจังหวะเดียว ก็เท่ากับไม่ได้ทำอะไรเลย — ที่คนลืมบ่อยที่สุดคือจังหวะสุดท้าย เพราะสองจังหวะแรกเขียนแล้วเห็นผลทันที ส่วนจังหวะสุดท้ายไม่เขียนก็ไม่มีอะไรพัง

ที่ไม่เหมือนกัน: ของจริงบริษัทขนส่งยังมีระบบตรวจของมันเองอยู่บ้าง แต่ Authorization Server ไม่ตรวจ state ให้เลยแม้แต่นิดเดียว — ผลรันข้างบนคือส่ง state=AAAA-not-random-1234 ไปก็ยังได้ code และไม่ส่ง state เลยก็ยังได้ code
ทั้งสามจังหวะเป็นงานของ client ฝ่ายเดียว และไม่มีใครฟ้องถ้าคุณลืม

วิธีที่ถูกต้องมีสามบรรทัด และต้องครบทั้งสาม:

จังหวะต้องทำอะไรพลาดตรงนี้แล้วเป็นยังไง
ก่อนพา browser ไป (ขั้น [1]) สุ่มค่าที่เดาไม่ได้ เช่น 32 byte จาก crypto/rand ถ้าใช้ค่าคงที่หรือค่าที่เดาได้ attacker ก็ใส่ค่านั้นในลิงก์ปลอมได้ → ป้องกันไม่ได้เลย
ตอนเดียวกัน เก็บค่านั้นไว้ใน session ของผู้ใช้คนนี้ (ฝั่ง server หรือ cookie ที่ปลอดภัย) ถ้าไม่เก็บ ก็ไม่มีอะไรให้เทียบตอนกลับมา
ตอน callback (ขั้น [7]) เทียบ state ที่กลับมากับที่เก็บไว้ · ไม่ตรง = ทิ้ง ห้ามแลก code ลืมข้อนี้ = ทั้งหมดข้างบนเสียเปล่า · นี่คือข้อที่คนลืมบ่อยที่สุด

หน้าตาในโค้ด Go

สามบรรทัดข้างบนแปลเป็น Go ได้สั้นมาก — ที่สำคัญคือใช้ crypto/rand ไม่ใช่ math/rand เพราะ math/rand เดาลำดับถัดไปได้ถ้ารู้ค่าก่อนหน้า
ข้างล่างนี้เป็นไฟล์เต็ม ๆ ไม่ใช่เศษโค้ด — ก๊อปทั้งก้อนไปวางเป็น main.go แล้ว go run main.go ได้เลย (ใช้แต่ของใน standard library จึงไม่ต้องมี go.mod และไม่ต้อง go get อะไรเลย):

// state/main.go
package main

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"fmt"
)

// [1] สุ่ม — เรียกก่อนพา browser ไปหา Authorization Server
func newState() string {
	b := make([]byte, 32)
	rand.Read(b) // doc ของ crypto/rand.Read เขียนว่า "It never returns an error"
	return base64.RawURLEncoding.EncodeToString(b)
}

// [3] เทียบ — เรียกตอน callback ก่อนเอา code ไปแลก
func sameState(a, b string) bool {
	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

// main() มีไว้ให้เห็นผลเท่านั้น — ของจริงเรียกสองฟังก์ชันข้างบนจาก handler
func main() {
	saved := newState() // ขั้น [1] สุ่มแล้วเก็บลง session ของผู้ใช้คนนี้
	fmt.Println("state ที่สุ่มได้ =", saved)
	fmt.Println("ความยาว =", len(saved), "ตัวอักษร")
	fmt.Println("สุ่มอีกรอบ =", newState())
	fmt.Println()
	fmt.Println("callback ส่งค่าเดิมกลับมา   -> sameState =", sameState(saved, saved))
	fmt.Println("callback ส่งค่าอื่นกลับมา   -> sameState =", sameState(saved, newState()))
	fmt.Println("callback ไม่ส่ง state กลับมา -> sameState =", sameState(saved, ""))
}

ผมก๊อปไฟล์นี้ไปวางในโฟลเดอร์เปล่า ๆ แล้วรันจริง ได้แบบนี้:

$ go run main.go
state ที่สุ่มได้ = 6YGcX2cU5S2uyIM7BF3tqImtqK3CLJY978uKvaQlOUA
ความยาว = 43 ตัวอักษร
สุ่มอีกรอบ = i596gzZwOl6c3gROb9tJPrlcsTj5tYPMhGAYVbkYLdQ

callback ส่งค่าเดิมกลับมา   -> sameState = true
callback ส่งค่าอื่นกลับมา   -> sameState = false
callback ไม่ส่ง state กลับมา -> sameState = false

# ค่าสุ่มของคุณจะไม่ตรงกับของผมแน่นอน — ที่ต้องตรงคือ 43 กับ true/false สามบรรทัดล่าง

สังเกตบรรทัดสุดท้าย: ถ้า callback ไม่มี state ติดมาเลย (ซึ่งคือสิ่งที่ลิงก์ปลอมของ attacker เป็น) sameState ก็คืน false ให้เอง — ตราบใดที่โค้ดคุณเรียกมัน จริง ๆ
ส่วน subtle.ConstantTimeCompare ใช้แทน a == b เพราะการเทียบ string ปกติ จะหยุดทันทีที่เจอตัวอักษรต่างกัน ทำให้เวลาที่ใช้บอกใบ้ได้ว่าเดาถูกไปกี่ตัวแล้ว — เป็นนิสัยที่ควรติดไว้กับทุกค่าลับ ไม่ใช่แค่ state

💡 ตัวช่วยจำ: state ตอบคำถามว่า "การเดินทางรอบนี้ เป็นรอบที่ฉันเองเป็นคนเริ่มหรือเปล่า"
ถ้า client ไม่เคยเริ่มการเดินทางที่มี state ค่านี้ ก็แปลว่า code ที่ถืออยู่ไม่ใช่ของผู้ใช้คนนี้
และเพราะ Authorization Server ทำหน้าที่นี้แทนไม่ได้ (มันไม่รู้จัก session ของเว็บเรา) งานนี้จึงตกเป็นของ client เสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป

สามคำนี้คือชื่อที่ dev ใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไปทั้งหัวข้อ

CSRF (Cross-Site Request Forgery)
การหลอกให้ browser ของเหยื่อยิง request ที่ attacker เป็นคนแต่งขึ้น โดยเหยื่อไม่รู้ตัว · ประโยคที่เอาไปใช้ได้เลย: "callback ตัวนี้กัน CSRF ยังไง เทียบ state ตรงบรรทัดไหน" — สื่อกว่าถาม senior ว่า "ตรงนี้ปลอดภัยไหม" เยอะ
constant-time comparison
การเทียบค่าลับที่ใช้เวลาเท่ากันเสมอ ไม่ว่าจะต่างกันตั้งแต่ตัวแรกหรือตัวสุดท้าย เพื่อไม่ให้เวลาที่ใช้บอกใบ้ว่าเดาถูกไปกี่ตัวแล้ว · ใน Go คือ subtle.ConstantTimeCompare ที่เพิ่งเห็นในโค้ดข้างบน · ประโยคที่เอาไปใช้ได้เลย: "ตรงนี้เทียบ secret ด้วย == อยู่ ควรเปลี่ยนเป็น constant-time compare ไหม"
CSPRNG (crypto/rand ไม่ใช่ math/rand)
ตัวสุ่มที่ออกแบบมาให้เดาค่าถัดไปไม่ได้แม้จะรู้ค่าก่อนหน้าทั้งหมด — ต่างจากตัวสุ่มธรรมดาที่เร็วกว่าแต่เดาลำดับได้ · พิมพ์ถาม AI ได้เลยว่า: "ค่านี้เป็นค่าที่ใช้ด้านความปลอดภัย ควรสุ่มด้วย crypto/rand หรือ math/rand"

⚠️ ระวังคำ: state ในภาษา OAuth ไม่เกี่ยวกับ "สถานะของหน้าจอ" แบบที่ใช้ในคำว่า state management ของ React เลยแม้แต่นิดเดียว — มันคือค่าสุ่มที่มีไว้กัน CSRF อย่างเดียว ชื่อพ้องกันเฉย ๆ

7 · scope กับหน้า consent

scope คือรายการสิทธิ์ที่ client ขอ เขียนเป็นคำ ๆ คั่นด้วยช่องว่าง
นี่คือส่วนที่ทำให้คำว่า "จำกัดขอบเขต" ในสไลด์หน้า 35 มีความหมายจริง — รหัสผ่านเปิดได้ทุกอย่าง แต่ token เปิดได้แค่ที่เขียนไว้ใน scope

พอ client ขอ scope มา Authorization Server ก็เอาไปแปลงเป็นภาษาคนแล้วถามเจ้าของบัญชี นี่คือข้อความจริงบนหน้า consent ของ Keycloak ที่ผมเปิดใช้แล้วรันจนเห็น:

Grant Access to Wongnok Web
Do you grant these access privileges?
  · Email address        ← มาจาก scope "email"
  · User roles           ← มาจาก scope "roles"
  · User profile         ← มาจาก scope "profile"
[ Yes ]   [ No ]

และถ้าผู้ใช้กด No — ผมกดจริงแล้วได้แบบนี้:

HTTP/1.1 302 Found
Location: http://localhost:3000/callback?error=access_denied&state=consent-demo&iss=…

ไม่มี code กลับมา มีแต่ error=access_denied — นี่คือเคสที่โค้ดฝั่ง callback ของเราต้องรองรับด้วย ไม่ใช่สมมติว่าจะได้ code เสมอ

กับดักที่ต้องรู้: scope ที่ขอ ≠ scope ที่ได้

ผมลองขอแค่ scope=openid อย่างเดียว แล้วดูว่า token ที่ได้มีอะไรบ้าง:

# ขอไป:      scope=openid
# ได้กลับมา:  scope = openid email profile     ← ได้เกินที่ขอ!
claim เกี่ยวกับตัวตนที่ติดมาใน id_token ทั้งที่ขอแค่ openid =
    sub, preferred_username, name, given_name, family_name, email, email_verified
# (id_token ก้อนนี้มี claim ทั้งหมด 18 ตัว ที่เหลือเป็นของระบบ เช่น iss, aud, exp, iat, jti, sid, typ, azp, acr, at_hash, auth_time)

สาเหตุอยู่ที่ Keycloak แบ่ง scope เป็นสองกอง และกองแรกใส่ให้เองทุกครั้งไม่ว่าจะขอหรือไม่:

ประเภทรายชื่อจริงของ client wongnok-webพฤติกรรม
default client scopes acr, basic, email, profile, roles, web-origins ติดมากับ token ทุกใบ แม้ไม่ได้ขอ
optional client scopes address, microprofile-jwt, offline_access, organization, phone ต้องขอเองใน scope= ถึงจะได้

และถ้าขอ scope ที่ไม่มีอยู่จริง มันจะไม่เงียบ:

# ขอ scope=openid banana
Location: http://localhost:3000/callback
    ?error=invalid_scope&error_description=Invalid+scopes%3A+openid+banana&state=sc-bad&iss=…
⚠️ อย่าเชื่อว่า "ขอน้อย = ได้น้อย" โดยไม่ตรวจ

ถ้าคุณออกแบบ API โดยคิดว่า "เว็บนี้ขอแค่ openid เลยไม่มีทางเห็นอีเมล" ผลรันข้างบนบอกว่าคิดผิด — อีเมลติดมาใน token อยู่ดี
ที่ถูกคือ ไปดูรายการ default client scopes ที่ Authorization Server แล้วตัดตัวที่ไม่ควรให้ออก ไม่ใช่หวังว่า client จะไม่ขอ
อีกเรื่อง: consent ถูกจำไว้ — ผมรัน flow เดิมซ้ำอีกรอบ Keycloak ไม่ถามอีกแล้ว ข้ามไปที่ callback เลย · ผู้ใช้จึงเห็นหน้าจอนั้นครั้งเดียว ต้องไปถอนเองทีหลังถ้าเปลี่ยนใจ

8 · กลับไปดูข้อ "ถอนสิทธิ์ไม่ได้" อีกที

หัวข้อ 1 บอกว่าปัญหาข้อ 3 ของ password anti-pattern คือถอนสิทธิ์ไม่ได้ ผมทดสอบว่า OAuth แก้ได้จริงไหม — เดิน flow จนได้ refresh_token มา แล้วยิงไปที่ revocation endpoint ของ Keycloak (นี่คือปุ่ม "ถอนสิทธิ์" ในรูปแบบ HTTP):

1) มี refresh_token อยู่ในมือ (len=694)

2) ลอง refresh ครั้งแรก:
   HTTP 200  keys = ['access_token', 'expires_in', 'refresh_expires_in', 'refresh_token',
                     'token_type', 'id_token', 'not-before-policy', 'session_state', 'scope']

3) ผู้ใช้กด "ถอนสิทธิ์" → POST /realms/wongnok/protocol/openid-connect/revoke
   HTTP 200

4) ลอง refresh อีกครั้งหลังถอนสิทธิ์:
   {"error":"invalid_grant","error_description":"Session not active"}
   HTTP 400

5) รหัสผ่านของ chef1 ยังใช้ล็อกอินได้เหมือนเดิมไหม:
   ล็อกอินใหม่แล้วได้ code ยาว 98 ตัวอักษร → ยังล็อกอินได้ปกติ ไม่ต้องเปลี่ยนรหัสผ่าน

นี่คือความต่างทั้งหมดในสี่บรรทัด
· แบบเก่า: ถอนสิทธิ์ = เปลี่ยนรหัสผ่าน = พังทุกเว็บพร้อมกัน
· แบบ OAuth: ถอนสิทธิ์ = ยกเลิก token ใบเดียว = เว็บอื่นไม่รู้เรื่องด้วยเลย และรหัสผ่านของผู้ใช้ไม่ถูกแตะต้อง

9 · แล้ว grant แบบอื่นล่ะ ทำไมต้องเป็น Authorization Code

OAuth 2.0 (RFC 6749, ปี 2012) นิยาม grant ไว้หลายแบบ และสองแบบในนั้นถูกเลิกแนะนำไปแล้ว — ตรงนี้สำคัญเพราะ tutorial เก่า ๆ ในอินเทอร์เน็ตยังสอนสองตัวนั้นอยู่เยอะมาก

Grant typeทำงานยังไงสถานะตอนนี้เพราะอะไร
Authorization Code ได้ code ทาง browser → เอาไปแลก token ทาง back channel ✅ ตัวหลัก ใช้กับเว็บ · mobile · SPA (คู่กับ PKCE) token ไม่เคยผ่าน browser · code ใช้ครั้งเดียว อายุสั้น ต้องมี secret มาแลก
Implicit
(response_type=token)
Authorization Server ส่ง access_token กลับมาใน URL fragment เลย ไม่มี code ❌ ถูกตัดออกจาก OAuth 2.1 token ไปนอนใน browser history · ถูก inject ได้ · sender-constrain ไม่ได้
Resource Owner Password Credentials
(password grant)
client รับ username/password ของผู้ใช้มาตรง ๆ แล้วส่งไปแลก token ❌ ถูกตัดออกจาก OAuth 2.1 · RFC 9700 เขียนว่า MUST NOT be used คือ password anti-pattern ในหัวข้อ 1 นั่นเอง · ใช้กับ 2FA ไม่ได้
Client Credentials ไม่มีผู้ใช้เลย · service คุยกับ service ด้วย client_id+client_secret ✅ ยังใช้ได้ แต่คนละงาน ใช้ตอน cron job หรือ service ยิงหากันเอง ไม่ได้ทำงานแทนผู้ใช้คนไหน
Refresh Token เอา refresh_token ไปขอ access_token ใบใหม่ ✅ ยังใช้ได้ ทำให้ access token อายุสั้นได้ (5 นาที) โดยผู้ใช้ไม่ต้องล็อกอินใหม่ทุก 5 นาที

อ้างอิงตรงตัวสำหรับข้ออ้างเรื่อง OAuth 2.1

ข้ออ้างว่า "OAuth 2.1 ตัด Implicit กับ Password grant ออก" ไม่ควรเชื่อเพราะมีคนพูดต่อ ๆ กันมา
เอกสารตัวจริงคือ draft-ietf-oauth-v2-1-15 (The OAuth 2.1 Authorization Framework · Hardt, Parecki, Lodderstedt · 2 มีนาคม 2026)
ในหัวข้อ 10 มีบรรทัดพวกนี้อยู่ตรงตัว:

*  The Implicit grant (response_type=token) is omitted from this
   specification as per Section 2.1.2 of [RFC9700]

*  The Resource Owner Password Credentials grant is omitted from this
   specification as per Section 2.4 of [RFC9700]

   [... ตัด bullet อีก 4 ข้อออก — bearer token ใน query string, refresh token
    ของ public client, redirect_uri ที่ token endpoint, client credentials ใน request body ...]

10.1.  Removal of the OAuth 2.0 Implicit grant

   The OAuth 2.0 Implicit grant is omitted from OAuth 2.1 as it was
   deprecated in [RFC9700].

   The intent of removing the Implicit grant is to no longer issue
   access tokens in the authorization response, as such tokens are
   vulnerable to leakage and injection, and are unable to be sender-
   constrained to a client.

และ RFC 9700 (Best Current Practice for OAuth 2.0 Security · BCP 240 · มกราคม 2025) ที่ถูกอ้างถึงนั้น §2.4 เขียนไว้แรงกว่านั้นอีก:

2.4.  Resource Owner Password Credentials Grant

   The resource owner password credentials grant [RFC6749] MUST NOT be
   used.  This grant type insecurely exposes the credentials of the
   resource owner to the client.

📌 ทำไมถึงต้องระวังเรื่องนี้เป็นพิเศษ — Authorization Server ส่วนใหญ่ ยังเปิด grant ที่ถูกเลิกแนะนำไว้อยู่ เพื่อความเข้ากันได้กับของเก่า
Keycloak 26.7.2 ที่ผมรัน ประกาศใน discovery endpoint ว่ารองรับทั้ง implicit และ password — รองรับ ไม่ได้แปลว่าควรใช้
เวลาเห็นตัวอย่างในเน็ตที่ยิง grant_type=password ไปที่ Keycloak ตรง ๆ (มันง่ายกว่าเยอะ ใช้ curl บรรทัดเดียวจบ) ให้รู้ไว้ว่า นั่นคือของที่ RFC 9700 เขียนว่า MUST NOT — ใช้ทดสอบในเครื่องตัวเองพอไหว แต่อย่าเอาขึ้นของจริง

ผมลองขอแบบ Implicit ดูจริง ๆ

ตอนสร้าง client wongnok-web ผมตั้ง implicitFlowEnabled: false ไว้ตั้งแต่แรก พอลองขอ response_type=token ได้ผลแบบนี้:

HTTP/1.1 302 Found
Location: http://localhost:3000/callback#error=unauthorized_client
    &error_description=Client+is+not+allowed+to+initiate+browser+login+with+given
      +response_type.+Implicit+flow+is+disabled+for+the+client.
    &state=s5&iss=…

สังเกตตัวอักษรตัวเดียวที่สำคัญมาก: # ไม่ใช่ ?
Implicit flow ตอบกลับทาง URL fragment (ส่วนหลัง #) ไม่ใช่ query string และfragment ไม่ถูกส่งไปหาเซิร์ฟเวอร์ — browser ตัดทิ้งก่อนยิง request
แปลว่าถ้าเป็น Implicit จริง ๆ token จะไม่โผล่ใน access log ของเรา (ต่างจาก code ที่โผล่) แต่มันจะไปโผล่ในประวัติ browser และโค้ด JavaScript ทุกตัวบนหน้านั้นอ่านได้ ซึ่งคือสิ่งที่ RFC 9700 §4.3.2 พูดถึงพอดี

⚠️ อย่าเข้าใจผิดว่า "fragment ปลอดภัยกว่าเพราะไม่ลง log"

เหตุผลจริงที่ Implicit ถูกตัด ไม่ใช่เรื่อง access log อย่างเดียว แต่คือประโยคใน OAuth 2.1 §10.1 ที่ยกมาข้างบน: token ที่ออกมาทาง authorization response นั้น "vulnerable to leakage and injection, and are unable to be sender-constrained to a client"
คำว่า injection คือหัวใจ — attacker เอา token ที่ขโมยมาจากที่อื่น ยัดใส่ callback ของเว็บเราได้ และเว็บเราแยกไม่ออกว่า token ใบนี้ออกมาให้ใคร เพราะไม่มีขั้นตอน back channel ที่จะไปถามยืนยัน
ส่วน Authorization Code ไม่มีปัญหานี้ เพราะเว็บเราเป็นคนไปแลก token เองกับ Authorization Server เราจึงรู้แน่นอนว่า token ใบนี้เพิ่งออกให้เราเมื่อกี้นี้เอง

10 · สรุปทั้งบทเป็นตารางเดียว

คำถามคำตอบสั้นหลักฐานในบทนี้
OAuth 2.0 แก้ปัญหาอะไร ให้แอปทำงานแทนผู้ใช้ได้ โดยแอปไม่ต้องเห็นรหัสผ่าน รหัสผ่าน chef1234 ถูก POST ไปที่ :8081 ไม่ใช่ :3000
ทำไมต้องมี code คั่นกลาง เพราะ URL ถูกจดไว้หลายที่ · code เปล่า ๆ ใช้ไม่ได้ แต่ token ใช้ได้ทันที access log มี code= 2 บรรทัด · มี access_token 0 บรรทัด
code ที่หลุดไป ทำอะไรได้ ไม่ได้อะไร ถ้าไม่มี client_secret · และใช้ได้ครั้งเดียวใน 60 วินาที 401 unauthorized_client · 400 Code not valid
state มีไว้ทำไม กัน CSRF — ยืนยันว่า callback นี้เกิดจากการเดินทางที่เราเริ่มเอง Keycloak echo ค่าอะไรก็ได้กลับมา และไม่บ่นเลยตอนไม่ส่ง state
scope มีไว้ทำไม จำกัดว่า token ใบนี้ใช้ทำอะไรได้ · เป็นที่มาของข้อความบนหน้า consent หน้า consent แสดง Email address / User profile / User roles
ทำไมไม่ใช้ Implicit หรือ Password grant ถูกตัดออกจาก OAuth 2.1 ทั้งคู่ · Password grant ถูกเขียนว่า MUST NOT be used ข้อความตรงตัวจาก draft-ietf-oauth-v2-1-15 §10 และ RFC 9700 §2.4

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

1. Authorization Server รู้แล้วว่าผู้ใช้เป็นใครและอนุญาตแล้ว ทำไมไม่ส่ง access_token กลับมาทาง URL เลยให้จบในขั้นตอนเดียว?

ผมยกเว็บ callback จริงด้วย Gin แล้วเดิน flow จนจบ · บรรทัดที่โผล่ใน gin-access.log คือ GET "/callback?state=…&code=76a08add-…" — query string ทั้งก้อนถูกเขียนลงไฟล์ เหมือนที่ บทเรียน 43 พิสูจน์เรื่องรหัสผ่านใน query string
ถ้าตรงนั้นเป็น token ก็คือ token ของผู้ใช้ทุกคนถูกเขียนลง log ทุกครั้งที่ล็อกอิน
RFC 9700 §4.3.2 เขียนไว้ว่า "Clients MUST NOT pass access tokens in a URI query parameter"
ส่วนตัวเลือกที่ 1 ผิด: URL รับได้ยาวกว่านั้นเยอะ และไม่ใช่ประเด็นด้านความปลอดภัย · ตัวเลือกที่ 2 ผิด: ทั้ง query string และ fragment ไม่ได้ถูกเข้ารหัสหรือไม่เข้ารหัสต่างกัน ทั้งคู่วิ่งใน TLS เหมือนกัน · ตัวเลือกที่ 4 ผิด: scope ถูกบอกไปตั้งแต่ขั้นแรกแล้ว และ token ที่ได้ก็คืน scope กลับมาด้วย
จุดที่ต้องจำ: ของที่ไม่ลับเดินทาง front channel · ของที่ลับเดินทาง back channel เท่านั้น

2. attacker เปิดไฟล์ access log ของเว็บเราเจอ แล้วเห็น code=9016d08d-… อยู่เต็มไปหมด — เขาเอาไปทำอะไรได้?

ผมทดสอบครบทั้งสี่ด่านด้วย code ที่ได้มาสด ๆ ทุกครั้ง:
· ไม่ส่ง client_secret → HTTP 401 {"error":"unauthorized_client"}
· ส่ง secret ผิด → HTTP 401 เหมือนกัน
· redirect_uri ไม่ตรง → HTTP 400 {"error":"invalid_grant","error_description":"Incorrect redirect_uri"}
· ใช้ซ้ำครั้งที่สอง → HTTP 400 {"error":"invalid_grant","error_description":"Code not valid"}
· รอ 65 วินาทีแล้วค่อยใช้ (realm ตั้ง accessCodeLifespan = 60) → Code not valid เหมือนกัน
ตัวเลือกที่ 2 กับ 3 ผิดเพราะ code ไม่ใช่ token — มันใช้ยิง API ไม่ได้เลย มีที่ใช้ที่เดียวคือ token endpoint · ตัวเลือกที่ 4 ผิดเพราะ code ของ Keycloak เป็น UUID สามก้อนต่อกัน ไม่มีข้อมูลผู้ใช้อยู่ข้างใน
แต่อย่าสรุปเกิน: RFC 9700 §4.3.1 ยังเตือนว่า code ที่ยังไม่มีใครใช้ถูก replay ได้ คำตอบที่ถูกจึงเป็น "แทบไม่ได้อะไร" ไม่ใช่ "ปลอดภัย 100%" — ส่วนที่เหลืออุดด้วย PKCE

3. ข้อไหนอธิบาย state ได้ถูกต้อง?

ผมทดสอบสองเคสกับ Keycloak ของจริง:
· ส่ง state=AAAA-not-random-1234 (เดาง่ายสุด ๆ) → มันส่งกลับมาเหมือนเดิมเป๊ะ และออก code ให้ปกติ
· ไม่ส่ง state เลย → ก็ยังได้ code กลับมาปกติ ไม่มีคำเตือนอะไรทั้งสิ้น
สองผลนี้ปิดตัวเลือกที่ 2 ไปเลย — Authorization Server ไม่ได้ตรวจอะไรให้
ตัวเลือกที่ 1 ผิด: state ไม่ควรมีข้อมูลผู้ใช้อยู่ข้างใน มันควรเป็นค่าสุ่มล้วน ๆ · ตัวเลือกที่ 3 ผิดเรื่องทิศทาง: client เป็นคนสุ่มและส่งไป ไม่ใช่รับมา
RFC 6749 §10.12 เขียนว่า "The client MUST implement CSRF protection for its redirection URI" — คำว่า client ในประโยคนั้นคือคำตอบทั้งหมด
จุดที่อันตราย: โค้ดที่ลืมเทียบ state ทำงานได้ปกติทุกอย่าง ล็อกอินผ่าน เทสผ่าน ขึ้น production ได้ — เป็นช่องโหว่ที่ไม่มีอาการจนกว่าจะโดนโจมตี

4. เพื่อนส่งโค้ดตัวอย่างมาให้ ใช้ grant_type=password ยิง username/password ของผู้ใช้ไปที่ Keycloak ตรง ๆ แล้วบอกว่า "สั้นกว่าเยอะ ใช้แบบนี้เถอะ" — ควรตอบว่าอะไร?

เอกสารตัวจริงเขียนไว้ตรงตัวสองที่:
· draft-ietf-oauth-v2-1-15 §10: "The Resource Owner Password Credentials grant is omitted from this specification as per Section 2.4 of [RFC9700]"
· RFC 9700 §2.4: "The resource owner password credentials grant [RFC6749] MUST NOT be used. This grant type insecurely exposes the credentials of the resource owner to the client."
ตัวเลือกที่ 1 คือกับดักที่เจอบ่อยที่สุด — Keycloak 26.7.2 รองรับจริง (มันขึ้นอยู่ใน grant_types_supported) แต่ รองรับ กับ ควรใช้ เป็นคนละเรื่อง Authorization Server เปิดไว้เพื่อความเข้ากันได้กับระบบเก่า
ตัวเลือกที่ 3 ผิดเพราะเหตุผลที่ยกมาไม่ใช่เรื่องความเร็ว (ที่จริง password grant ยิงรอบเดียวจบ เร็วกว่าด้วยซ้ำ) · ตัวเลือกที่ 4 ผิดสองชั้น: OAuth 2.1 §10.1 ตัด Implicit ออกเช่นกัน ไม่ได้แนะนำเลย
เหตุผลที่แท้จริงกลับไปที่หัวข้อ 1: password grant คือ password anti-pattern ที่ใส่ชื่อทางการให้ — client ยังเห็นรหัสผ่านอยู่ดี · จำกัดขอบเขตไม่ได้ · และใช้กับ 2FA ไม่ได้

🛠️ ภารกิจ — เดิน Authorization Code Grant ด้วยมือตัวเอง

เป้าหมายไม่ใช่ "ทำให้ล็อกอินได้" — เป้าหมายคือ เห็นด้วยตาตัวเองว่า token ไม่เคยผ่าน browser และ code เปล่า ๆ ใช้อะไรไม่ได้
ทำได้โดยไม่ต้องเขียน Go สักบรรทัด ใช้แค่ docker-compose กับ curl

  1. ยก Authorization Server ขึ้นมา: เขียน docker-compose.yml ที่ใช้ quay.io/keycloak/keycloak:26.7 · command: ["start-dev"] · map พอร์ต 8081:8080 · ตั้ง KC_BOOTSTRAP_ADMIN_USERNAME กับ KC_BOOTSTRAP_ADMIN_PASSWORD เป็น admin
    รอจน curl http://localhost:8081/realms/master/.well-known/openid-configuration ตอบ HTTP 200 (ของผมใช้เวลา 7 วินาที)
  2. ตั้งของให้ครบสามอย่างผ่านหน้าเว็บ http://localhost:8081 (ล็อกอิน admin/admin):
    · realm ชื่อ wongnok
    · client ชื่อ wongnok-web · ที่หัวข้อ Capability config → เปิด Client authentication (เปิด = confidential · จะมี secret ให้ไปก๊อปที่แท็บ Credentials → ช่อง Client Secret) · เปิด Standard flow · ปิด Implicit flow · ตั้ง Valid redirect URIs เป็น http://localhost:3000/callback
    (หมายเหตุ: Keycloak รุ่นเก่ามีสวิตช์ชื่อ Public client ให้ปิด — console ของ 26.7.2 ไม่มีชื่อนั้นแล้ว เหลือแต่ Client authentication ที่ทำหน้าที่กลับด้านกัน)
    · user ชื่อ chef1 พร้อมตั้งรหัสผ่านแบบไม่ temporary
  3. อ่านแผนที่ก่อนเดิน: เปิด http://localhost:8081/realms/wongnok/.well-known/openid-configuration แล้วหาสองบรรทัดนี้ให้เจอ: authorization_endpoint กับ token_endpoint
    ตอบให้ได้ว่าอันไหนคือ front channel อันไหนคือ back channel
  4. เดินขั้น [2] ด้วย browser จริง: ประกอบ URL ของ authorization_endpoint ให้ครบห้าพารามิเตอร์ (response_type, client_id, redirect_uri, scope, state) แล้ววางในช่อง address
    ล็อกอินด้วย chef1 → browser จะเด้งไป localhost:3000 แล้วขึ้นว่าต่อไม่ติด (เพราะยังไม่มีเว็บที่นั่น) ไม่เป็นไร — สิ่งที่ต้องการอยู่ในช่อง address แล้ว
    ก๊อป URL ทั้งเส้นออกมา แล้วตอบว่าเห็นคำว่า access_token ไหม
  5. แลก code ด้วย curl: POST ไปที่ token_endpoint ด้วย grant_type=authorization_code + code + client_id + client_secret + redirect_uri
    ต้องได้ HTTP 200 พร้อม access_token · จด expires_in ที่ได้ไว้ด้วย
  6. ยิงซ้ำด้วย code เดิมทันที — จดข้อความ error ที่ได้แบบตัวต่อตัว
  7. ทำสามเคสพังให้ครบ (ขอ code ใหม่ทุกครั้ง เพราะข้อ 6 เผาไปแล้วหนึ่งใบ):
    ก. ตัด client_secret ออก · ข. ใส่ client_secret มั่ว · ค. เปลี่ยน redirect_uri ตอนแลกให้ต่างจากตอนขอ
    จด HTTP status กับข้อความ error ของทั้งสามเคส แล้วตอบว่าเคสไหนได้ invalid_grant เคสไหนได้ unauthorized_client และทำไมถึงต่างกัน
  8. วัดอายุ code: ขอ code ใหม่ นั่งรอ 65 วินาที แล้วค่อยยิง
    ตอบว่าข้อความ error ต่างจากข้อ 6 ไหม
  9. ลอง Implicit: เปลี่ยน response_type=code เป็น response_type=token แล้ววาง URL ในช่อง address อีกครั้ง
    ดู URL ที่ browser เด้งไป แล้วตอบว่าเครื่องหมายก่อน error= เป็น ? หรือ # และมันต่างกันยังไง
  10. ท้าทาย: เปิด Consent required ที่ client แล้วเดิน flow ใหม่ · กด No ที่หน้า consent
    แล้วตอบสองข้อ: URL ที่เด้งกลับมามี code ไหม และ ถ้าโค้ด callback ของคุณเขียนแค่ code := c.Query("code") แล้วเอาไปแลกเลย จะเกิดอะไรขึ้นในเคสนี้
  11. เก็บกวาด: docker-compose down -v แล้วเช็คด้วย docker ps -a กับ docker volume ls ว่าไม่มีของค้าง

ผลรันจริงของทั้งสามเคส:

ก. ไม่มี client_secret  -> HTTP 401  {"error":"unauthorized_client",
                              "error_description":"Invalid client or Invalid client credentials"}
ข. client_secret มั่ว    -> HTTP 401  {"error":"unauthorized_client", …ข้อความเดียวกันเป๊ะ}
ค. redirect_uri ไม่ตรง   -> HTTP 400  {"error":"invalid_grant",
                              "error_description":"Incorrect redirect_uri"}

ความต่างอยู่ที่ คำถามคนละข้อถูกถามคนละจังหวะ:
· unauthorized_client + 401 ตอบคำถามว่า "แกเป็น client ตัวนี้จริงหรือเปล่า" — ด่านนี้อยู่ก่อน Keycloak จะสนใจ code ด้วยซ้ำ · ตอบไม่ได้ = ยังไม่ได้เข้าประตูเลย จึงเป็น 401 (Unauthorized)
· invalid_grant + 400 ตอบคำถามว่า "ใบรับของที่แกยื่นมาใช้ได้ไหม" — ผ่านด่านแรกแล้ว Keycloak รู้แล้วว่าแกคือ wongnok-web จริง แต่ของที่ยื่นมาไม่ถูกต้อง จึงเป็น 400 (Bad Request)

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

และเคส ค. คือด่านที่หลายคนมองข้าม: redirect_uri ต้องส่งไปทั้งตอนขอ code และตอนแลก token และต้องตรงกันเป๊ะ ๆ · Keycloak จดเหตุผลไว้ในล็อกว่า Saved redirectUri: http://localhost:3000/callback, redirectUri parameter: http://localhost:3000/evil
ด่านนี้มีไว้ผูก code ติดกับปลายทางเดิม เพื่อไม่ให้ใครเอา code ไปแลกแล้วสั่งให้ส่งผลไปที่อื่น

💬 ข้อที่ผมอยากให้ทำจริง ๆ คือข้อ 4 กับข้อ 9 — เพราะสองข้อนี้ทำให้คุณเห็นกับตาว่าอะไรอยู่ในช่อง address ของ browser ตัวเอง ซึ่งเป็นสิ่งเดียวที่ทำให้คำว่า front channel / back channel เลิกเป็นศัพท์ท่องจำ
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Keycloak 26.7.2 (จาก tag 26.7) กับ Docker server 29.5.2 ผ่าน colima
ที่ต่างกันได้แน่ ๆ คือ code · state · session_state · sub เพราะเป็นค่าสุ่มใหม่ทุกครั้ง — ให้ดูรูปร่างกับข้อความ error ว่าตรงกันไหม ไม่ใช่ดูค่า

11 · บทถัดไป

📌 บทนี้ใช้ Keycloak เป็นกล่องดำมาตลอด — บทหน้าเราจะเปิดกล่องนั้น
ตลอดหน้านี้ผมพูดว่า "Authorization Server" แล้วชี้ไปที่ localhost:8081 โดยยังไม่ได้อธิบายเลยว่ามันคืออะไร ตั้งยังไง หรือทำไมต้องรันเอง
บทเรียน 51 คือเรื่องนั้น — Keycloak คือ Authorization Server แบบ open source ที่เรารันเองได้ด้วย docker-compose ก้อนเดียว แทนที่จะต้องไปสมัคร Google Cloud Console เพื่อขอ client_id
สิ่งที่จะได้เห็นในบทหน้า: realm คืออะไร · client ต่างกับ user ยังไง · role กับ group ทำงานร่วมกันแบบไหน · และวิธีที่ API ฝั่งเราตรวจ token ด้วย public key จาก jwks_uri โดยไม่ต้องแชร์ความลับใด ๆ กับ Keycloak
คำศัพท์ทุกคำในบทนี้ (authorization_endpoint, token_endpoint, scope, client_secret) จะกลายเป็นช่องกรอกจริง ๆ บนหน้าจอ Keycloak

อ้างอิง