OAuth 2.0 กับ OpenID Connect ต่างกันอย่างไร
OAuth 2.0 ให้แอปได้ access token ไปเรียก API แทนผู้ใช้ ส่วน OpenID Connect เพิ่ม ID token ที่บอกว่าใคร sign in ไล่ authorization code flow กับ PKCE ทีละ request
ปานกลาง7 นาทีในการอ่าน
app auth server API │ │ │ │ 1 /authorize + challenge │ │ ●──────────────────────────▶│ │ │ │──┐ 2 user signs in │ │ │◀─┘ │ │ 3 302 ?code=...&state= │ │ │◀──────────────────────────│ │ │ 4 POST /token + verifier │ │ │──────────────────────────▶│ │ │ 5 access_token, id_token │ │ │◀──────────────────────────│ │ │ 6 GET /api Authorization: Bearer │ │────────────────────────────────────────────────────▶│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ id_token who signed in: iss sub aud exp nonce access_token what the app may call at the API OpenID Connect adds the id_token to OAuth 2.0
ในบทความนี้
OAuth 2.0 เป็นเรื่อง authorization คือให้แอปได้ access token ไปเรียก API แทนผู้ใช้ โดยแอปไม่ต้องรู้รหัสผ่านของผู้ใช้ ส่วน OpenID Connect (OIDC) เป็นเรื่อง authentication คือชั้นที่วางบน OAuth 2.0 เพื่อให้แอปรู้ว่าใครเป็นคน sign in ผ่าน token อีกตัวที่ชื่อ ID token
ทั้งสองใช้ flow เดียวกัน ต่างกันที่ request ของ OIDC ใส่ openid ไว้ใน scope และ token response มี id_token เพิ่มมา บทความนี้ไล่ authorization code flow แบบมี PKCE ทีละ request แล้วดูว่า token แต่ละตัวมีไว้ให้ใครอ่านครับ
app auth server API │ │ │ │ 1 /authorize + challenge │ │ ●──────────────────────────▶│ │ │ │──┐ 2 user signs in │ │ │◀─┘ │ │ 3 302 ?code=...&state= │ │ │◀──────────────────────────│ │ │ 4 POST /token + verifier │ │ │──────────────────────────▶│ │ │ 5 access_token, id_token │ │ │◀──────────────────────────│ │ │ 6 GET /api Authorization: Bearer │ │────────────────────────────────────────────────────▶│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ id_token who signed in: iss sub aud exp nonce access_token what the app may call at the API OpenID Connect adds the id_token to OAuth 2.0
OAuth 2.0 ใช้ทำอะไร
OAuth 2.0 ใช้ให้แอปของบุคคลที่สามได้สิทธิ์เข้าถึง HTTP service แบบจำกัด โดยเจ้าของข้อมูลเป็นคนอนุมัติ RFC 6749 แบ่งบทบาทไว้สี่บทบาท
| บทบาท | ในตัวอย่างนี้ |
|---|---|
| resource owner | ผู้ใช้ที่เป็นเจ้าของข้อมูล |
| client | แอปที่ต้องการเรียก API |
| authorization server | server ที่ยืนยันตัวผู้ใช้และออก token |
| resource server | API ที่รับ access token |
ผลลัพธ์ของ OAuth คือ access token RFC 6749 นิยามว่าเป็นข้อมูลที่แทนสิทธิ์ที่ผู้ใช้ให้กับ client และปกติ client อ่านความหมายข้างในไม่ได้ (opaque) (หัวข้อ 1.4) token แทน scope และระยะเวลาที่ได้รับอนุญาต และ resource server เป็นคนบังคับใช้ ข้อนี้คือจุดที่ทำให้ OAuth อย่างเดียวไม่พอสำหรับการ sign in เพราะ token ถูกออกแบบมาให้ API อ่าน ไม่ได้ออกแบบมาให้แอปใช้รู้ว่าผู้ใช้เป็นใคร
OpenID Connect เพิ่มอะไรเข้ามา
OIDC เพิ่ม ID token OpenID Connect Core 1.0 เรียกตัวเองว่าชั้น identity แบบเรียบง่ายที่วางบน OAuth 2.0 ซึ่งทำให้ client ยืนยันตัวตนของผู้ใช้ได้จากการ authenticate ที่ authorization server ทำ และสิ่งที่เพิ่มเข้ามาหลักคือ ID token ซึ่งอยู่ในรูป JWT และต้องมีลายเซ็นด้วย JWS
claim ที่ ID token ต้องมีทุกครั้งได้แก่ (OIDC Core หัวข้อ 2)
issURL ของผู้ออก tokensubID ของผู้ใช้ที่ไม่ซ้ำและไม่ถูกนำกลับมาใช้ใหม่ภายใน issuer นั้นaudผู้รับ token ซึ่งต้องมีclient_idของแอปexpและiatเวลาหมดอายุและเวลาออก
ส่วน nonce ใช้ผูก session ของ client เข้ากับ ID token และกัน replay ถ้าแอปส่ง nonce ไปใน request ค่าเดียวกันจะกลับมาใน ID token
นอกจาก ID token แล้ว OIDC ยังกำหนด UserInfo endpoint ซึ่งเป็น OAuth 2.0 protected resource ที่คืน claim ของผู้ใช้ แอปเรียกด้วย access token ที่ได้จาก flow เดียวกัน และ request ของ OIDC ต้องมี scope openid ทุกครั้ง
Authorization code flow กับ PKCE ทำงานอย่างไร
flow นี้มีหกขั้น แอปส่ง browser ไปที่ authorization server ผู้ใช้ sign in แอปได้ code กลับมา แล้วเอา code ไปแลก token ผ่านช่องทางตรง PKCE (RFC 7636) เพิ่มค่าลับหนึ่งค่าที่ผูก request แรกกับ request แลก token เข้าด้วยกัน ค่าในตัวอย่างข้างล่างมาจากตัวอย่างใน RFC 6749, RFC 7636 และ OIDC Core
ขั้นที่ 1 แอปสร้าง code_verifier และ code_challenge
ก่อนเริ่ม แอปสุ่มค่า code_verifier ความยาวระหว่าง 43 ถึง 128 ตัวอักษร แล้วคำนวณ code_challenge จากสูตร
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
คู่ค่านี้มาจากภาคผนวก B ของ RFC 7636 วิธีนี้เรียกว่า S256 และ client ที่ทำได้ต้องใช้ S256 ส่วน code_verifier เก็บไว้ในแอป ไม่ส่งออกไปในขั้นนี้
ขั้นที่ 2 แอปส่ง browser ไปที่ /authorize
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&scope=openid%20profile
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: server.example.com
response_type=code บังคับต้องมี และบอกว่าขอ authorization code ส่วน state คือค่าที่แอปตั้งเองซึ่ง authorization server จะส่งกลับมาตอน redirect และควรใช้กัน CSRF scope=openid ทำให้ request นี้เป็น OIDC ถ้าไม่มี openid ก็เป็น OAuth ธรรมดา
ขั้นที่ 3 ผู้ใช้ sign in แล้ว browser ได้ code กลับมา
authorization server ยืนยันตัวผู้ใช้และถามว่าอนุญาตหรือไม่ จากนั้น redirect browser กลับไปที่ redirect_uri
HTTP/1.1 302 Found
Location: https://client.example.org/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
แอปต้องตรวจว่า state ตรงกับที่ส่งไป code นี้มีอายุสั้น RFC 6749 แนะนำให้อายุไม่เกิน 10 นาที และใช้ได้ครั้งเดียว ถ้ามีคนเอา code เดิมมาใช้ซ้ำ server ต้องปฏิเสธ
code ผ่าน browser และ URL มาแล้ว จึงอาจรั่วได้ ปัญหาที่ PKCE ถูกออกแบบมาแก้คือการดักเอา authorization code ไป เช่นแอปอันตรายบนเครื่องเดียวกันที่ลงทะเบียน custom URI scheme เดียวกับแอปจริงไว้
ขั้นที่ 4 แอปแลก code เป็น token
แอปส่ง request ตรงไปที่ token endpoint โดยไม่ผ่าน browser
POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
server คำนวณ code challenge จาก code_verifier ที่ได้รับแล้วเทียบกับ code_challenge ที่เก็บไว้ ตอนออก code ถ้าไม่ตรงก็ไม่ออก token ผู้ที่ดักได้แค่ code จึงแลก token ไม่ได้ เพราะไม่มี code_verifier ซึ่งไม่เคยผ่าน browser
ขั้นที่ 5 แอปได้ access token และ ID token
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "SlAV32hkKG",
"token_type": "Bearer",
"refresh_token": "8xLOxBtZp8",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ...."
}
ตัวอย่างนี้มาจากtoken response ใน OIDC Core ถ้า decode ส่วน payload ของ id_token ในตัวอย่างนั้นจะได้ claim ประมาณนี้
{
"iss": "http://server.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"nonce": "n-0S6_WzA2Mj",
"exp": 1311281970,
"iat": 1311280970
}
ก่อนเชื่อ ID token แอปต้องตรวจตามที่ OIDC Core กำหนด ได้แก่ issuer ต้องตรงกับค่า iss ทุกตัวอักษร aud ต้องมี client_id ของแอป และ token ต้องไม่หมดอายุ (iss ในตัวอย่างของ spec ขึ้นต้นด้วย http แต่ spec กำหนดให้ค่าจริงเป็น URL แบบ https) ถ้าได้ ID token มาทางช่องทางตรงกับ token endpoint แบบในขั้นนี้ OIDC Core ให้ใช้การตรวจ TLS server แทนการตรวจลายเซ็นได้ แต่ ID token ที่ได้มาทางอื่นต้องตรวจลายเซ็นด้วย key ของ issuer
ขั้นที่ 6 แอปเรียก API ด้วย access token
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer SlAV32hkKG
รูปแบบ Authorization: Bearer มาจาก RFC 6750 ซึ่งนิยาม bearer token ว่าใครก็ตามที่ถือ token ใช้มันได้แบบเดียวกับคนอื่นที่ถือ โดยไม่ต้องพิสูจน์ว่ามี key ลับ access token จึงต้องเก็บให้ดีเท่ากับรหัสผ่านครับ
ID token กับ access token ต่างกันอย่างไร
ID token มีไว้ให้แอปอ่านเพื่อรู้ว่าใคร sign in ส่วน access token มีไว้ให้ API อ่านเพื่อตัดสินว่าอนุญาต request นี้หรือไม่ สองตัวนี้สลับกันใช้ไม่ได้
| ID token | access token | |
|---|---|---|
| มาจาก spec | OpenID Connect Core | OAuth 2.0 (RFC 6749) |
| ผู้รับ | client (aud คือ client_id) |
resource server |
| รูปแบบ | JWT ที่มีลายเซ็นเสมอ | ไม่กำหนด อาจเป็นค่าสุ่มหรือ JWT |
| ใช้ทำอะไร | บอกว่าใคร sign in เมื่อไร | เรียก API ตาม scope |
| ส่งไปที่ API | ไม่ควร | ใช่ ใน Authorization: Bearer |
ถ้า access token เป็นค่าสุ่ม API ถาม authorization server ได้ผ่าน token introspection (RFC 7662) ซึ่งส่ง request แบบ POST แล้วได้คำตอบว่า token ยัง active หรือไม่ ถ้า access token เป็น JWT ก็มีมาตรฐาน RFC 9068 ซึ่งกำหนดให้ใส่ typ เป็น at+jwt เหตุผลหนึ่งที่ RFC 9068 ระบุไว้คือกันไม่ให้ resource server รับ ID token ของ OIDC เป็น access token เพราะทั้งคู่เป็น JWT ที่มาจาก issuer เดียวกันได้
ความผิดพลาดที่เกิดจากการสลับสองตัวนี้มีสองแบบ แบบแรกคือ API รับ ID token แล้วถือว่าเป็นสิทธิ์เรียก API ทั้งที่ aud ของ ID token คือแอป ไม่ใช่ API แบบที่สองคือแอปเอา access token มาใช้เป็นหลักฐานว่าผู้ใช้ sign in แล้ว ทั้งที่ access token ไม่ได้บอกว่าผู้ใช้เป็นใคร และแอปก็ไม่ควรต้องอ่านมัน
RFC 9700 เปลี่ยนคำแนะนำอะไรบ้าง
RFC 9700 (มกราคม 2568) คือ Best Current Practice ด้านความปลอดภัยของ OAuth 2.0 ซึ่งปรับปรุงคำแนะนำของ RFC 6749, 6750 และ 6819 และเลิกใช้บางโหมดที่ไม่ปลอดภัย ข้อที่กระทบ flow ข้างบนโดยตรงมีดังนี้
- PKCE สำหรับทุก client public client ต้องใช้ PKCE ส่วน confidential client แนะนำให้ใช้ และ authorization server ต้องรองรับ PKCE RFC ยังเขียนไว้ว่าแม้ PKCE จะออกแบบมาสำหรับ native app แต่คำแนะนำนี้ใช้กับ OAuth client ทุกแบบ รวมถึง web application
- ใช้
S256วิธีที่ไม่เปิดเผย verifier ใน request แรก ตอนนี้มีแค่S256 - เลิกใช้ implicit grant client ไม่ควรใช้
response_type=tokenหรือ response type อื่นที่ส่ง access token กลับมาใน authorization response - ห้ามใช้ password grant resource owner password credentials grant ต้องไม่ถูกใช้ เพราะทำให้ client เห็นรหัสผ่านของผู้ใช้
- เทียบ redirect URI แบบตรงทุกตัวอักษร authorization server ต้องใช้ exact string matching ยกเว้น port ของ localhost สำหรับ native app
- จำกัดผู้รับและผู้ใช้ token access token ควรจำกัด audience ให้ resource server ตัวเดียวหรือไม่กี่ตัว และ refresh token ของ public client ต้องผูกกับผู้ส่ง (sender-constrained) หรือใช้ refresh token rotation
สำหรับ OIDC RFC 9700 มีข้อยกเว้นหนึ่งข้อ คือ confidential client ของ OIDC อาจใช้ nonce แทน PKCE ได้ ถ้าทำตามข้อควรระวังเพิ่มเติมในหัวข้อ 4.5.3.2 แต่ถ้าเริ่มระบบใหม่ ผมใช้ทั้ง PKCE และ nonce ไปเลย เพราะทำเพิ่มแค่ไม่กี่บรรทัด และไม่ต้องมานั่งเช็กว่าเข้าเงื่อนไขข้อยกเว้นหรือไม่ครับ
แหล่งอ้างอิง
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
- RFC 7662: OAuth 2.0 Token Introspection
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
- RFC 9700: Best Current Practice for OAuth 2.0 Security
- OpenID Connect Core 1.0 incorporating errata set 2
อ่านต่อ
บทความที่เกี่ยวข้อง
การพัฒนาซอฟต์แวร์
RPC กับ REST แตกต่างกันอย่างไร และควรเลือกใช้แบบไหน
RPC เรียก procedure บน server ด้วยชื่อและพารามิเตอร์ ส่วน REST ส่งคำขอไปที่ resource ผ่าน interface กลางของ HTTP เทียบทีละประเด็นพร้อมตัวอย่าง gRPC และ JSON-RPC อิงจาก Fielding, RFC 9110 และ spec ต้นทาง
เริ่มต้น10 นาที
- API
- RPC
- REST
- gRPC
- JSON-RPC
- HTTP
การพัฒนาซอฟต์แวร์
WebSocket SSE และ Polling ต่างกันอย่างไร ควรเลือกแบบไหน
Polling ส่ง request ใหม่ทุกรอบ SSE เปิด response ค้างให้ server ส่ง event ทางเดียว WebSocket upgrade แล้วส่ง frame ได้สองทาง พร้อมเรื่อง reconnect และ proxy
ปานกลาง9 นาที
- WebSocket
- Server-Sent Events
- SSE
- Long Polling
- HTTP
- Reverse Proxy
- nginx
การพัฒนาซอฟต์แวร์
Docker กับ Kubernetes ต่างกันอย่างไร เมื่อไหร่ต้องใช้ Kubernetes
Docker ใช้ build image และรัน container บนเครื่องหนึ่งเครื่อง ส่วน Kubernetes ดูแล container บนหลายเครื่อง ทั้งจัดวาง Pod รักษาจำนวน replica และเปิด Service
เริ่มต้น5 นาที
- Docker
- Kubernetes
- Containers
- Docker Compose
- OCI
- DevOps