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

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 ขอจริง (subset): openid pid given_name middle_name family_name title house_address title_en given_name_en family_name_en → claim ใน id_token จะมีเฉพาะชุดนี้ (ไม่มี gender/ial/address/birthdate เพราะไม่ได้ขอ). birthdate เคยถูกเพิ่มเช้า 2026-09-04 แล้วถูกถอดออก 11:28 UTC วันเดียวกัน (เหตุผลไม่ได้บันทึก) — วันเกิดจึงไม่ prefill จาก ThaID ประชาชนกรอกเอง/แอดมินแก้ที่ /portal/settings/users; ชุดชื่ออังกฤษ เพิ่มบน UAT 2026-09-04 (kc-uat-thaid-claims.sh — ตาราง CLAIMS ในสคริปต์คือแหล่งความจริงว่า scope ไหน → attribute/claim ชื่ออะไร) — รูปแบบค่า birthdate ที่ DOPA ส่ง ยังไม่ยืนยันจาก login จริง (spec OIDC = YYYY-MM-DD ค.ศ.); webportal backend/src/utils/thaidBirthdate.ts รับทั้ง ISO / YYYYMMDD / DD/MM/YYYY และปี พ.ศ. และ log รูปร่างค่าที่อ่านไม่ออกไว้ให้ปรับตาม.


ขั้นที่ 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
  &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"
}

ขั้นที่ 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": "99/9 หมู่ 9 ตำบลทดสอบ อำเภอทดสอบ จังหวัดทดสอบ 99999",
  "title_en": "Mr.",
  "given_name_en": "Thodsob",
  "family_name_en": "Rabobthaid"
}

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 (mapper ยังอยู่ แต่ scope ถูกถอด 2026-09-04 → ไม่มี claim)
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, titleEn, firstNameEn, lastNameEn — จำเป็นสำหรับประชาชน transient ที่ไม่มี user ให้ admin API อ่าน). S9 ใช้ชุดอังกฤษ prefill ส่วน "ข้อมูลผู้ใช้งาน" ของ /permission-request (ส่งขึ้น FE เป็น userProfile.nameEn ไม่ได้เก็บลง DB). ตัวอย่าง 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",

  "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 ได้.