Animated Architecture Patterns

OAuth 2.0 Authorization Code + PKCE

flow เข้าสู่ระบบมาตรฐานของเว็บแอป แอปมือถือ และ single-page app: code มาทาง browser ส่วน token มาทาง back channel

หรือที่เรียกกันว่า Authorization Code Flow with PKCE, PKCE flow (Proof Key for Code Exchange)

App client: SPA, mobile, web Browser user agent + user Authorization Server login, consent, tokens API resource server 302 → /authorize?…&code_challenge GET /authorize?response_type=codeclient_id=s6BhdRkqt3redirect_uri=https://app/cbscope=openid orders:readstate=xyzcode_challenge=E9Melhoa2Ow…code_challenge_method=S256 random secret, 43–128 chars code_challenge = BASE64URL(SHA-256(verifier)) stored login, MFA and consent pages user signs in, clicks Allow 302 → /cb?code=SplxlO…&state=xyz GET /cb?code=SplxlO…&state=xyz front channel: code is in the URL state matches POST /tokenback channel: direct HTTPS 200 { access_token, refresh_token, id_token } grant_type=authorization_codecode=SplxlOBeZQQYbYS6WxSbIAredirect_uri=https://app/cbclient_id=s6BhdRkqt3code_verifier=dBjftJeZ4CVP… BASE64URL(SHA-256(code_verifier)) == code_challenge stolen code without the verifier → invalid_grant JWKS (cached) GET /ordersAuthorization: Bearer eyJhbGciOi… 200 OK [ orders ] signature (JWKS) iss, aud, exp scope orders:read code_challenge access_token code_verifier

ขั้นที่ 1 · ส่ง authorization request App สุ่ม code_verifier แบบใช้ครั้งเดียวขึ้นมา (ยาว 43–128 ตัวอักษร) แล้วเก็บไว้กับตัวเอง จากนั้นก็ redirect browser ไปที่ authorization endpoint พร้อม response_type=code, client_id, redirect_uri, scope, state ที่เดาไม่ได้ และ code_challenge = BASE64URL(SHA-256(code_verifier)) คู่กับ code_challenge_method=S256 ฝั่ง authorization server ก็เก็บ challenge นี้ไว้คู่กับ request

ขั้นที่ 2 · เข้าสู่ระบบและให้ consent authorization server ยืนยันตัวตนผู้ใช้ (password, passkey, MFA, federated login) แล้วขอ consent สำหรับ scope ที่ขอมา App ไม่เคยเห็น credential ของผู้ใช้เลย จากนั้น server ก็ redirect browser ไปที่ redirect_uri?code=…&state=… แล้ว App ก็ตรวจว่า state ตรงกับค่าที่ตัวเองส่งไป ช่วงนี้คือ front channel: code วิ่งอยู่ใน URL ที่ browser history, log หรือแอปไม่หวังดีที่อ้างว่าใช้ redirect URI เดียวกันมองเห็นได้ เพราะฉะนั้นแค่ถือ code อย่างเดียวต้องยังแลก token ไม่ได้

ขั้นที่ 3 · แลก code เป็น token รอบนี้ไปทาง back channel (HTTPS request ตรง ๆ ไม่ใช่ redirect ผ่าน browser): App ส่ง POST พร้อม code และ code_verifier ของตัวเองไปที่ token endpoint แล้วฝั่ง server ก็ตรวจว่า hash SHA-256 ของ verifier ตรงกับ code_challenge ที่เก็บไว้ (และดูด้วยว่า code ยังไม่เคยใช้ ยังไม่หมดอายุ และออกให้ client_id กับ redirect_uri นี้จริง) แล้วก็คืน access token มาให้ ส่วนใหญ่จะมี refresh token มาด้วย และถ้าใช้ OpenID Connect ก็มี ID token อีกตัว ใครขโมย code ไปก็เอาไปใช้ไม่ได้ถ้าไม่มี verifier: server จะตอบ invalid_grant

ขั้นที่ 4 · เรียก API App ส่ง Authorization: Bearer <access_token> ไปกับ request แล้ว API ก็ตรวจ JWT เองในเครื่อง: ตรวจ signature กับ public key ของ authorization server (JWKS ที่ cache ไว้ และจะดึงใหม่เมื่อเจอ kid ที่ไม่รู้จัก) ต่อด้วย iss, aud, exp และ scope ที่ต้องใช้ แล้วตอบ 200 OK พอ access token หมดอายุ App ก็ใช้ refresh token ขอตัวใหม่ ไม่ต้องพาผู้ใช้ไปเดิน flow ทั้งหมดใหม่อีกรอบ

ปัญหา

แอปต้องเรียก API แทนผู้ใช้ แอปต้องไม่แตะ password ของผู้ใช้เลย และ token ที่ได้มาก็ต้องไม่รั่วระหว่างทาง วิธีรุ่นเก่าแต่ละแบบมีรูรั่วของตัวเอง: resource owner password grant ส่ง credential ให้แอปถือ ส่วน implicit grant ส่ง access token กลับมาใน fragment ของ redirect URL ทำให้มันไปค้างอยู่ใน browser history และ script ไหนบนหน้าก็อ่านได้ authorization code grant แบบธรรมดาดีกว่า เพราะมีแค่ code อายุสั้นที่ใช้ได้ครั้งเดียววิ่งผ่าน browser ส่วน token กลับมาทาง request ตรง แต่ public client (single-page app, mobile app และ desktop app) เก็บ client secret ไม่ได้ ใครที่ดัก code ได้ก็เลยแลกมันได้เหมือนเป็นตัวแอปเอง: จะเป็นแอปไม่หวังดีที่ register custom URL scheme เดียวกัน header Referer หรือ log file ก็ได้

ทำงานยัง⁠ไง

PKCE (Proof Key for Code Exchange, RFC 7636 อ่านว่า "pixy") ผูก code ไว้กับ instance ของแอปที่เริ่ม flow:

  1. Authorization request (front channel) แอปสร้าง code_verifier ตัวใหม่ที่ entropy สูง แล้วเก็บไว้กับตัวเอง มันส่งไปแค่ code_challenge = BASE64URL(SHA-256(code_verifier)) กับ code_challenge_method=S256 พร้อม response_type=code, client_id, redirect_uri, scope และ state ตามปกติ
  2. เข้าสู่ระบบและให้ consent authorization server ยืนยันตัวตนผู้ใช้และขอ consent แล้ว redirect browser กลับมาที่แอปพร้อม code (ที่ผูกกับ challenge ที่เก็บไว้) และ state ตัวเดิม
  3. แลก code (back channel) แอปส่ง code กับ code_verifier ไปที่ token endpoint ผ่าน HTTPS request ตรง ๆ แล้วฝั่ง server ก็ hash verifier มาเทียบกับ challenge ที่เก็บไว้ มีแค่ instance ที่สร้าง verifier เท่านั้นที่ผ่าน ทำให้ code ที่โดนดักไปไม่มีค่าอะไร
  4. ใช้ token แอปส่ง access token ไปเป็น bearer token แล้ว API ก็ validate และตอบ request ส่วน refresh token ใช้ขอ access token ใหม่ทีหลังโดยไม่ต้องให้ผู้ใช้ทำอะไร

hash ทำงานได้ทางเดียว การเห็น challenge ในขั้นที่ 1 ไม่ได้เผย verifier และการเห็น code ในขั้นที่ 2 ก็ไม่พอจะเอาไปแลก

ใช้ตอนไหนดี

  • แอปพลิเคชันไหนก็ได้ที่ sign in ผู้ใช้และเรียก API แทนเขา: single-page app, native mobile app กับ desktop app และ server-side web app
  • มันคือ default สำหรับงานใหม่ ตัว OAuth 2.0 Security Best Current Practice (RFC 9700) บังคับให้ public client ใช้ PKCE และแนะนำให้ confidential client ใช้ด้วย ส่วน draft ของ OAuth 2.1 ก็ใส่มันไว้ใน authorization code grant เลย
  • ถ้าใช้คู่กับ OpenID Connect ตัว flow เดียวกันนี้ก็ sign in ผู้ใช้ไปด้วย: เพิ่ม openid ใน scope แล้ว token response ก็จะมี ID token มาด้วย
  • ไม่เหมาะกับ service-to-service call ที่ไม่มีผู้ใช้ (ใช้ client credentials) หรืออุปกรณ์ที่ไม่มี browser ให้ใช้ได้จริง เช่น TV และ CLI (ใช้ device authorization grant)

ได้อะไร เสียอะไร

  • ชิ้นส่วนเยอะกว่า implicit flow มี redirect สองรอบ, token request, การเก็บ token และการ refresh ทางที่ดีคือใช้ SDK ของ identity provider หรือ library ที่ certified แล้ว แทนที่จะเขียนเอง
  • PKCE ป้องกัน code ไม่ได้ป้องกัน token bearer token ใช้ได้กับใครก็ตามที่ถือมันอยู่ ให้ access token อายุสั้นและ scope แคบ และพิจารณาใช้ sender-constrained token (DPoP, RFC 9449 หรือ mutual TLS, RFC 8705) กับ API ที่มีมูลค่าสูง
  • browser เป็นที่ที่เก็บ token ยากที่สุด XSS จุดไหนก็ได้ใน single-page app ทำตัวเป็นแอปและใช้ token ของมันได้ แนวทางของ IETF สำหรับ browser-based app เลยเลือก backend-for-frontend ที่รัน flow นี้เป็น confidential client และให้ browser ถือแค่ HttpOnly session cookie
  • refresh token เป็นความลับอายุยาว สำหรับ public client ตัว RFC 9700 บังคับให้ refresh token เป็นแบบ sender-constrained หรือ rotate ทุกครั้งที่ใช้
  • JWT แบบ self-contained revoke ทันทีไม่ได้ API เชื่อ JWT จนกว่ามันจะหมดอายุ ถ้าอยากตัดสิทธิ์เร็วกว่านั้น ให้ตั้งอายุสั้น ๆ หรือใช้ token introspection (RFC 7662)

ข้อควรรู้ตอนลงมือทำ

  • Verifier: 32 byte จาก random generator ที่ปลอดภัยทาง cryptography แล้ว encode เป็น base64url แบบไม่มี padding (43 ตัวอักษร) ใช้ตัวใหม่ทุก authorization request เก็บไว้คู่กับ state และลบทั้งคู่ทิ้งหลังแลกเสร็จ ใช้ S256 เสมอ: method plain ส่งตัว verifier ไปตรง ๆ และมีไว้แค่สำหรับ client ที่คำนวณ SHA-256 ไม่ได้
  • state และ nonce: ส่ง state ที่เดาไม่ได้ไป แล้วตรวจมันตอน callback ตัว PKCE (เมื่อ server บังคับใช้) และ nonce ของ OpenID Connect ก็กัน CSRF ได้เหมือนกัน แต่ state ต้นทุนต่ำและพา context ของแอปข้าม redirect ไปได้ด้วย
  • Redirect URI: register ให้ตรงเป๊ะ server ต้องเทียบแบบ exact string match ยกเว้นว่าต้องยอมรับ port ไหนก็ได้บน loopback redirect URI ของ native app (RFC 9700, RFC 8252 §7.3) ส่วน native app เปิด system browser และไม่ใช้ embedded web view เด็ดขาด แล้วรับ redirect ผ่าน HTTPS link ที่ claim ไว้, private-use URI scheme หรือ loopback address (RFC 8252)
  • Confidential client (web backend) ต้อง authenticate ที่ token endpoint ด้วย โดยควรใช้ private_key_jwt หรือ mutual TLS แทน shared secret แล้วก็ยังใช้ PKCE ซ้อนไปอีกชั้น
  • Authorization server: ให้ code ใช้ได้ครั้งเดียวและอายุสั้น (RFC 6749 แนะนำไม่เกิน 10 นาที) ผูกมันไว้กับ client_id, redirect_uri และ challenge และปฏิเสธ token request ที่มี code_verifier มาทั้งที่ authorization request ไม่มี challenge (PKCE downgrade)
  • API: validate JWT access token ในเครื่อง (RFC 9068): ตรวจ signature ด้วย key จาก JWKS ของ issuer (cache ไว้ และดึงใหม่เมื่อเจอ kid ที่ไม่รู้จัก) แล้วตรวจ iss, aud, exp และ scope ที่ต้องใช้ ส่วน opaque token ให้ส่งไปที่ introspection endpoint แทน
  • ผลิตภัณฑ์: identity provider หลัก ๆ ทุกเจ้ารองรับ flow นี้ เช่น Microsoft Entra ID, Amazon Cognito, Google Identity, Okta, Auth0, Keycloak และ Ping Identity ส่วน client library ก็มี MSAL, AppAuth (iOS และ Android), oidc-client-ts, openid-client (Node.js) และ Spring Security