ข้ามไปที่เนื้อหา

ThaiD Login — โครงสร้าง Response ทุกขั้นตอน

บันทึกโครงสร้าง response ที่ได้กลับมาตอน login ด้วย ThaID (Digital ID กรมการปกครอง) ผ่าน Keycloak identity broker (alias thaid) — ตั้งแต่ redirect ไป DOPA จนถึง token ที่แอป RLPD ได้รับจริง. Endpoint และรายชื่อ scope ยืนยันจาก discovery document จริง ของ DOPA (ดึงเมื่อ 12 ส.ค. 2569).

ตัวอย่างทั้งหมดในหน้านี้เป็น MOCK ไม่ใช่ข้อมูลจริง

เลขบัตรประชาชน ชื่อ ที่อยู่ และ token ทุกตัวในหน้านี้แต่งขึ้นทั้งหมด (เลข 1111111111119 เป็นเลขที่ผ่าน checksum mod-11 แต่ไม่มีอยู่จริง). ห้ามนำ response จริงจาก production มาวางในรีโปนี้ — ไซต์ KB เป็นสาธารณะ และ payload จริงมี PII (เลขบัตร + ที่อยู่ตามทะเบียนบ้าน).

ภาพรวม flow

Browser ──(1) login + kc_idp_hint=thaid──▶ Keycloak (realm rlpd)
Keycloak ──(2) redirect authorize──▶ DOPA imauth.bora.dopa.go.th (สแกน ThaID app)
DOPA ──(3) callback ?code=...&state=...──▶ Keycloak /broker/thaid/endpoint
Keycloak ──(4) POST token endpoint──▶ DOPA คืน access_token + id_token (ES256)
Keycloak ──(5) IdP mappers + JIT──▶ สร้าง/หา user (username = pid, group ประชาชน)
Keycloak ──(6) ออก token ของ realm เอง──▶ แอป RLPD (S1–S9) ได้ token Keycloak ไม่ใช่ token DOPA

จุดสำคัญ: แอปของเราไม่เคยเห็น response จาก DOPA โดยตรง — Keycloak เป็นคนคุย กับ DOPA แล้วแปลงเป็น user attribute / claim ของ realm rlpd ให้. Response DOPA (ข้อ 3–4) จะเห็นได้เฉพาะใน Keycloak (log / admin) เท่านั้น.

Endpoint จริงของ ThaID (จาก discovery)

https://imauth.bora.dopa.go.th/.well-known/openid-configuration

รายการ ค่า
issuer https://imauth.bora.dopa.go.th
authorization_endpoint https://imauth.bora.dopa.go.th/api/v2/oauth2/auth/
token_endpoint https://imauth.bora.dopa.go.th/api/v2/oauth2/token/
userinfo_endpoint https://imauth.bora.dopa.go.th/api/v2/oauth2/userinfo/
jwks_uri https://imauth.bora.dopa.go.th/jwks/
id_token signing ES256 (ตัวเดียว)
grant types authorization_code, refresh_token
token auth client_secret_basic, client_secret_post — realm เราใช้ client_secret_post

Scope ที่ ThaID มีให้ทั้งหมด: openid pid address gender birthdate given_name middle_name family_name name given_name_en middle_name_en family_name_en name_en title title_en ial smartcard_code date_of_expiry date_of_issuance house_address

Scope ที่ realm rlpd ขอจริง (UAT ตั้งแต่ 2026-09-10 04:43 UTC): openid pid given_name middle_name family_name title house_address title_en given_name_en family_name_en birthdate gender → claim ใน id_token มีได้เฉพาะชุดนี้ (ไม่มี ial/address/smartcard_code เพราะไม่ได้ขอ) และเฉพาะ scope ที่ DOPA อนุมัติให้ RP ของกรมฯ — DOPA ไม่ตอบ invalid_scope แต่ตัด scope ที่ไม่ได้รับสิทธิ์ทิ้งเงียบ ๆ (ดู "วิธีตรวจว่า DOPA ส่งอะไรกลับมาจริง" ด้านล่าง). ประวัติ: birthdate เคยถูกเพิ่มเช้า 2026-09-04 แล้วถอดออก 11:28 UTC วันเดียวกันเพราะ RP ยังไม่ได้รับ scope; 2026-09-10 กรมฯ แจ้งว่า DOPA อนุมัติ birthdate + gender แล้ว จึงใส่กลับพร้อม gender (kc-uat-thaid-claims.sh — ตาราง CLAIMS ในสคริปต์คือแหล่งความจริงว่า scope ไหน → attribute/claim ชื่ออะไร). ที่อยู่ตามทะเบียนบ้านใช้ scope house_address (ไม่ใช่ address มาตรฐาน OIDC — discovery มีทั้งคู่).

รูปแบบค่าที่ DOPA ส่งจริง (ยืนยันจาก user บน UAT):

claim รูปแบบจริง หมายเหตุ
birthdate YYYY-MM-DD ค.ศ. (เช่น 1990-01-31) เห็นบน user ที่ login ช่วง scope เปิดเช้า 2026-09-04; webportal utils/thaidBirthdate.ts รับ ISO / YYYYMMDD / DD/MM/YYYY / พ.ศ. และ log รูปร่างค่าที่อ่านไม่ออก
house_address object → Keycloak เก็บเป็น string {id=<เลข 11 หลัก>, formatted=<ที่อยู่เต็ม>, raw=<เลขที่#หมู่#ตรอก#ซอย#ถนน#ตำบล#อำเภอ#จังหวัด>} webportal user.service.ts parseHouseAddress แยกจาก raw; ไม่มีรหัสไปรษณีย์ใน raw (ดึงจาก formatted ถ้ามี)
title คำนำหน้าไทย นาย/นาง/นางสาว… ตรง master PORTAL.Title
title_en / given_name_en / family_name_en Mr. / Somchai / Jaidee
gender ยังไม่ยืนยัน (ยังไม่มี login หลังเปิด scope) webportal utils/thaidGender.ts รับ male/female, M/F, 1/2, ชาย/หญิง → ป้าย PORTAL.Gender; ค่าอื่น log ThaiD gender claim present but unrecognised, shape=… — เจอแล้วให้เติม alias

ขั้นที่ 1–2 — Redirect ไป DOPA (mock)

Keycloak พาผู้ใช้ไปที่ (ตัดบรรทัดเพื่ออ่านง่าย):

https://imauth.bora.dopa.go.th/api/v2/oauth2/auth/
  ?client_id=MOCK-RLPD-CLIENT-ID
  &redirect_uri=https%3A%2F%2Fsso.rlpd.go.th%2Frealms%2Frlpd%2Fbroker%2Fthaid%2Fendpoint
  &response_type=code
  &scope=openid+pid+given_name+middle_name+family_name+title+house_address+title_en+given_name_en+family_name_en+birthdate+gender
  &state=MOCK_STATE_a1b2c3

ผู้ใช้สแกน QR / ยืนยันในแอป ThaID แล้ว DOPA ตอบกลับด้วย HTTP 302 มาที่ broker endpoint ของ Keycloak:

https://sso.rlpd.go.th/realms/rlpd/broker/thaid/endpoint
  ?code=MOCK_AUTH_CODE_x9y8z7
  &state=MOCK_STATE_a1b2c3

ขั้นที่ 3 — Token response จาก DOPA (mock)

Keycloak POST ไป token endpoint (พร้อม client_secret_post) ได้ JSON กลับ:

{
  "access_token": "MOCK_ACCESS_TOKEN_do-not-use-1234567890",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "MOCK_REFRESH_TOKEN_do-not-use-0987654321",
  "id_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ik1PQ0stS0lEIn0.<payload>.<signature>",
  "scope": "openid pid given_name middle_name family_name title house_address title_en given_name_en family_name_en"
}

scope ในคำตอบ = scope ที่ DOPA อนุมัติจริง ไม่ใช่ที่เราขอ

ตัวอย่างข้างบนคือค่าที่เห็นจริงบน UAT ก่อนเปิด birthdate gender: RP ขอ scope ไหนที่ยังไม่ได้รับสิทธิ์ DOPA จะตัดออกจาก scope ของ token (และไม่ใส่ claim นั้นใน id_token) โดยไม่ error — login ผ่านปกติ แต่ attribute ปลายทางว่าง (birthdate: []). ค่านี้อยู่ใน refresh token ของ DOPA (claim scope ใน JWT HS256 ของ DOPA — ดูวิธีอ่านด้านล่าง).

ขั้นที่ 4 — id_token ถอดรหัสแล้ว (mock)

Header:

{ "alg": "ES256", "typ": "JWT", "kid": "MOCK-KID-01" }

Payload (เฉพาะ claim ตาม scope ที่เราขอ + claim มาตรฐาน OIDC):

{
  "iss": "https://imauth.bora.dopa.go.th",
  "sub": "1111111111119",
  "aud": "MOCK-RLPD-CLIENT-ID",
  "iat": 1770000000,
  "exp": 1770003600,
  "nonce": "MOCK_NONCE_q1w2e3",

  "pid": "1111111111119",
  "title": "นาย",
  "given_name": "ทดสอบ",
  "middle_name": "",
  "family_name": "ระบบไทยดี",
  "house_address": { "id": "99999999999", "formatted": "99/9 หมู่ที่ 9 ต.ทดสอบ อ.ทดสอบ จ.ทดสอบ", "raw": "99/9#9####ทดสอบ#ทดสอบ#ทดสอบ" },
  "title_en": "Mr.",
  "given_name_en": "Thodsob",
  "family_name_en": "Rabobthaid",
  "birthdate": "1990-01-31",
  "gender": "<ยังไม่ยืนยันรูปแบบ>"
}

(house_address เป็น object — Keycloak oidc-user-attribute-idp-mapper เก็บเป็น string รูป {id=…, formatted=…, raw=…} ตามที่เห็นจริงบน attribute houseAddress; birthdate/gender มาเฉพาะเมื่อ DOPA อนุมัติ scope ให้ RP แล้ว.)

userinfo_endpoint คืน claim ชุดเดียวกัน (JSON ธรรมดา ไม่ใช่ JWT).

สมมติฐาน sub = pid — ต้อง verify กับ login จริงครั้งแรกบน prod

โค้ด thaid-link ใน web_portal (thaidLink.service.ts) เก็บ federated identity ด้วยค่า pid โดยสมมติว่า sub ใน id_token เท่ากับ pid — ถ้า login จริง ครั้งแรกพบว่าไม่เท่ากัน ต้องเปลี่ยนไปเก็บค่า sub จริงแทน (มี NOTE ไว้ในโค้ดแล้ว).

ขั้นที่ 5 — Keycloak แปลง claim → user (IdP mappers + JIT)

IdP mapper claim ThaID ปลายทางบน user
pid-mapper pid attribute pid
title-mapper title attribute title
houseAddress-mapper house_address attribute houseAddress
birthdate-mapper birthdate attribute birthdate (scope ถอด 2026-09-04, ใส่กลับ 2026-09-10 หลัง DOPA อนุมัติ)
gender-mapper gender attribute gender (UAT ตั้งแต่ 2026-09-10; webportal แปลงเป็นป้าย PORTAL.Gender ตอน login)
titleEn-mapper title_en attribute titleEn (UAT ตั้งแต่ 2026-09-04)
firstNameEn-mapper given_name_en attribute firstNameEn (UAT ตั้งแต่ 2026-09-04)
lastNameEn-mapper family_name_en attribute lastNameEn (UAT ตั้งแต่ 2026-09-04)
default-group-mapper — (hardcode) group /RLPD001/ประชาชน

ผล JIT ครั้งแรก: สร้าง user ใหม่ username = pid, attribute user_type=citizen, ไม่มี client role ใด ๆ (first broker login flow = Auto Linking).

syncMode ของ mapper ต้องเป็น IMPORT/INHERIT ห้าม FORCE

ถ้าตั้ง FORCE — ตอนเจ้าหน้าที่ (user AD) ผูก ThaID แล้ว login ครั้งถัดไป mapper จะเขียนทับ attribute ของ user AD (เช่น pid ที่มาจาก LDAP postalCode) ทำให้บัญชี staff พัง. บทเรียนจริงจากการ verify บน UAT (ส.ค. 2569). ตั้งแต่ 4 ก.ย. 2569 ใช้กับ title/titleEn/firstNameEn/lastNameEn/birthdate ด้วย: บัญชี AD ได้ค่าพวกนี้จาก AD ผ่าน LDAP mapper read-only (kc-uat-ldap-profile-mappers.sh — AD personalTitle, msDS-cloudExtensionAttribute1-4 ที่ S9 เขียนตอน provision) ส่วนประชาชน transient ถูก import ใหม่ทุก login จึงได้ค่าจาก ThaID เสมอแม้เป็น INHERIT.

ขั้นที่ 6 — Token ที่แอป RLPD ได้รับจริง (mock)

หลัง broker สำเร็จ Keycloak ออก access token ของ realm rlpd เอง — claim ThaID โผล่ผ่าน protocol mappers ของ client (pidMapper, titleMapper, houseAddressMapper, descriptionMapper) และ client scope thaid-profile (title, houseAddress, birthdate, gender, titleEn, firstNameEn, lastNameEn — จำเป็นสำหรับประชาชน transient ที่ไม่มี user ให้ admin API อ่าน). S9 ใช้ชุดอังกฤษ prefill ส่วน "ข้อมูลผู้ใช้งาน" ของ /permission-request (ส่งขึ้น FE เป็น userProfile.nameEn ไม่ได้เก็บลง DB) และใช้ title/gender/birthdate/houseAddress prefill คำนำหน้า เพศ วันเกิด ที่อยู่ทะเบียนบ้านใน /petition-request (gender ส่งขึ้น FE เป็น userProfile.gender ไม่มีคอลัมน์ เช่นเดียวกับ nameEn). ตัวอย่าง payload (ตัดเหลือส่วนสำคัญ):

{
  "iss": "https://sso.rlpd.go.th/realms/rlpd",
  "sub": "0f000000-mock-0000-0000-000000000000",
  "preferred_username": "1111111111119",
  "given_name": "ทดสอบ",
  "family_name": "ระบบไทยดี",

  "pid": "1111111111119",
  "title": "นาย",
  "houseAddress": "99/9 หมู่ 9 ตำบลทดสอบ อำเภอทดสอบ จังหวัดทดสอบ 99999",
  "titleEn": "Mr.",
  "firstNameEn": "Thodsob",
  "lastNameEn": "Rabobthaid",
  "birthdate": "1990-01-31",
  "gender": "<ตามที่ DOPA ส่ง — S9 แปลงเองตอน login>",

  "groups": ["/RLPD001/ประชาชน"],
  "resource_access": {}
}

ข้อสังเกตที่เจอมาแล้วตอนทำจริง:

  • ประชาชนไม่มี client role ต่อระบบ → resource_access ว่าง. ระบบที่ gate ด้วย role (เช่น S8 CMS) ต้องรองรับ กรณีนี้ ไม่งั้นเด้ง error=sso วนลูป.
  • Portal (S9) อ่านสิทธิ์จาก groups เท่านั้น — ถ้า user ไม่มี group จะเจอ 403 จอเปล่า (group ประชาชนจึงต้องถูกใส่โดย default-group-mapper เสมอ).
  • UserProfile.Pid ใน DB portal เป็น UNIQUE + 13 ตัวอักษร — pid จาก ThaID ใส่ได้พอดี แต่ user AD ที่ไม่มี pid ต้องใช้ placeholder ที่ยาวไม่เกิน 13 (มีบั๊กเก่าบันทึกไว้).
  • Backend ฝั่งเรา validate เลขบัตรด้วย checksum mod-11 (isValidThaiCitizenId) — ตัวอย่าง mock ในหน้านี้เลือกเลขที่ผ่าน checksum เพื่อให้ใช้ทดสอบ flow ได้.

วิธีตรวจว่า DOPA ส่ง claim อะไรกลับมาจริง (UAT, 2026-09-10)

ประชาชนบน UAT เป็น transient user (doNotStoreUsers=true, sub ขึ้นต้น lightweight-) จึงไม่มี user ให้ดู attribute ใน admin console — แต่ Keycloak 26 (persistent user sessions) เก็บ session ลง Postgres ตาราง offline_user_session (offline_flag='0' = online) และคอลัมน์ data มี session notes สองตัวที่ตอบคำถามนี้ได้ตรง ๆ:

  • notes.keycloak.userModel — user ชั่วคราวทั้งตัวรวม attributes หลังผ่าน IdP mappers (birthdate: [] = mapper ทำงานแต่ claim ไม่มา)
  • notes.FEDERATED_REFRESH_TOKEN — refresh token ของ DOPA (JWT HS256) ซึ่งมี claim scope = scope ที่ DOPA อนุมัติจริง ให้ login ครั้งนั้น
# บนกล่อง UAT (.155) — มาสก์เลขบัตร/ที่อยู่ก่อนวางที่ไหน
docker exec rlpd-postgres psql -U rlpd_admin -d rlpd_uat -At -c \
  "select to_timestamp(created_on) at time zone 'utc', data from offline_user_session
   where user_id like 'lightweight-%' order by created_on desc limit 3" \
| python3 -c '
import sys,json,base64
for line in sys.stdin:
    t,_,d=line.partition("|"); j=json.loads(d)
    tok=j["notes"]["FEDERATED_REFRESH_TOKEN"].split(".")[1]; tok+="="*(-len(tok)%4)
    print(t[:19], "granted:", json.loads(base64.urlsafe_b64decode(tok))["scope"])
    print("  attrs:", {k:v for k,v in json.loads(j["notes"]["keycloak.userModel"])["attributes"].items() if k in ("birthdate","gender","houseAddress")})'

ผลที่เห็นจริง 2026-09-10 04:43:31 UTC (login ThaID 12 วินาทีก่อน สคริปต์เพิ่ม scope ที่ 04:43:43): granted: openid pid given_name middle_name family_name title house_address title_en given_name_en family_name_en → ยังไม่มี birthdate gender, attribute birthdate: [] — ยังไม่ใช่หลักฐานว่า DOPA อนุมัติแล้ว ต้อง login ThaID ใหม่หลังเวลานั้นแล้วรันคำสั่งข้างบนซ้ำ: ถ้า granted มี birthdate gender และ attributes.gender มีค่า = ใช้ได้ (จดรูปแบบ gender ลงตารางด้านบน); ถ้า granted ไม่มี = DOPA ยังไม่ให้ สิทธิ์ RP (login ไม่ error) ต้องตามเรื่องกับ DOPA ไม่ใช่แก้ Keycloak.