เมื่อการยืนยันตัวตนใน Docker ทำให้เข้าสู่ระบบบนมือถือไม่ได้: เรื่องบั๊กข้ามแพลตฟอร์ม
เรื่องเริ่มจากคำร้องในบ่ายวันพฤหัสบดีว่า “เข้าสู่ระบบด้วย Google บนมือถือไม่ได้” ในกระทู้ฝ่ายช่วยเหลือมีผู้ใช้เพียงสามราย ทั้งหมดใช้ iOS และพบอาการเดียวกันคือวงหมุนในขั้นตอน OAuth ที่ไม่สิ้นสุด เว็บแอปและเดสก์ท็อปยังใช้งานได้ ปัญหาเกิดเฉพาะบนมือถือและเฉพาะการเข้าสู่ระบบผ่าน OAuth ส่วนการยืนยันตัวตนด้วยอีเมลและรหัสผ่านยังทำงานตามปกติ
รายงานข้อผิดพลาดลักษณะนี้ดูเหมือนจะแก้ได้เร็ว แต่กลับทำให้สับสนทั้งบ่าย อาการยืนยันตัวตนล้มเหลวบนแพลตฟอร์มเดียวชวนให้ตรวจหลายจุดพร้อมกัน ทั้ง redirect URI, CORS, การแลกเปลี่ยนโทเคน การเก็บเซสชัน และการตั้งค่าฝั่งไคลเอนต์ ทุกข้อดูเป็นไปได้ แต่ไม่มีข้อใดเป็นสาเหตุจริง
ระบบที่ใช้งาน
แอปพลิเคชันใช้ Supabase ที่ติดตั้งและดูแลเอง โดยนำ Docker Compose ทั้งชุดขึ้นใช้งานบนเซิร์ฟเวอร์เฉพาะ สถาปัตยกรรม Supabase แยกการยืนยันตัวตนเป็นไมโครเซอร์วิส GoTrue ซึ่งดูแลขั้นตอน OAuth การออกโทเคน และการจัดการผู้ใช้ GoTrue เชื่อมต่อ PostgreSQL โดยตรงเพื่ออ่านและเขียนข้อมูลในสคีมา auth ส่วน frontend เป็นแอป React Native ที่เชื่อมต่อ Supabase ผ่านไลบรารีไคลเอนต์ JavaScript มาตรฐาน
รูปแบบนี้พบได้ค่อนข้างบ่อยในทีมที่ต้องการควบคุมข้อมูลเอง Supabase ที่ติดตั้งเองให้ทั้งฐานข้อมูล PostgreSQL เต็มรูปแบบ ชั้นยืนยันตัวตน การรับข้อมูลแบบเรียลไทม์ผ่านการสมัครติดตาม และบริการจัดเก็บไฟล์ ทั้งหมดทำงานในคอนเทนเนอร์ที่ทีมดูแล ข้อแลกเปลี่ยน ซึ่งเหตุการณ์นี้เป็นตัวอย่างตรงประเด็น คือรายละเอียดการตั้งค่าที่บริการซึ่งผู้ให้บริการดูแลระบบให้จัดการอยู่เบื้องหลัง อาจกลายเป็นจุดขัดข้องที่เห็นชัดเมื่อทีมต้องดูแลเอง
สมมติฐานแรก: redirect URI ไม่ตรงกัน
เมื่อ OAuth บนมือถือผิดพลาด เรามักเริ่มตรวจจาก redirect URI แอปเนทีฟใช้ URL scheme เฉพาะ เช่น myapp://auth/callback แทน URL แบบ HTTPS หาก callback ที่ลงทะเบียนไว้ไม่ตรงกับที่ผู้ให้บริการ OAuth คาดไว้ จะเกิดอาการล้มเหลวเงียบแบบเดียวกับที่เราพบ: ขั้นตอน OAuth เริ่ม เบราว์เซอร์เปิด ผู้ใช้ยืนยันตัวตน แต่ callback ไม่เสร็จสมบูรณ์
เราตรวจการตั้งค่า redirect URI ใน Google Cloud Console การตั้งค่าโครงการ Supabase และ deep link ของแอป React Native ทุกอย่างตรงกัน URI ลงทะเบียนถูกต้องทั้งสองฝั่ง หน้าขอความยินยอม OAuth เสร็จสมบูรณ์โดยไม่มีข้อผิดพลาด Google ยอมรับการเข้าสู่ระบบ ปัญหาเกิดหลัง callback ที่จุดใดจุดหนึ่งระหว่างการแลกเปลี่ยนโทเคน
สมมติฐานที่สอง: CORS
อีกจุดที่มักตรวจเมื่อแก้ปัญหา OAuth บนมือถือคือเฮดเดอร์ Cross-Origin Resource Sharing เมื่อแอปมือถือส่งคำขอไปยัง backend บนโดเมนเฉพาะ การตั้งค่า CORS กำหนดว่าแบบจำลองความปลอดภัยของเบราว์เซอร์จะอนุญาตคำขอหรือไม่ นโยบาย CORS ที่ตั้งผิดอาจทำให้เกิดข้อผิดพลาดที่ดูเหมือนการยืนยันตัวตนล้มเหลวทุกประการ
แต่ CORS เป็นข้อจำกัดที่เบราว์เซอร์บังคับใช้ คำขอที่แอปเนทีฟส่งผ่านไคลเอนต์ HTTP ของตัวเองไม่อยู่ภายใต้ข้อจำกัดนี้ และการที่อีเมลยังใช้ได้แต่ OAuth ใช้ไม่ได้ก็ไม่สอดคล้องกับปัญหา CORS เพราะทั้งคู่ใช้ endpoint ของ GoTrue เดียวกัน หาก CORS ปฏิเสธคำขอ ก็น่าจะส่งผลต่อทั้งสองแบบ ไม่ใช่เฉพาะ OAuth เราเพิ่มการบันทึกล็อกอย่างละเอียดในไคลเอนต์มือถือ พบว่าคำขอถึงบริการ GoTrue และบริการตอบกลับด้วยรหัสสถานะ 500
อ่านล็อกจริง
การตรวจล็อกคอนเทนเนอร์เป็นจุดที่การแก้ปัญหาระบบติดตั้งเองต่างจากบริการที่ผู้ให้บริการดูแลระบบให้มากที่สุด Supabase Cloud แสดงล็อกแบบมีโครงสร้างจากทุกบริการผ่านแดชบอร์ด แต่ Docker ที่ดูแลเองต้องอ่านล็อกจากแต่ละคอนเทนเนอร์โดยตรง:
docker logs supabase-auth --since 1h 2>&1 | grep -i error
คอนเทนเนอร์ GoTrue บันทึกข้อผิดพลาดต่อเนื่อง โดยบรรทัดสำคัญคือ:
level=error msg="Error creating oauth state" error="ERROR: function gen_random_uuid() does not exist (SQLSTATE 42883)"
นี่ไม่ใช่ข้อผิดพลาดการเปลี่ยนเส้นทาง OAuth แต่เป็นปัญหา PostgreSQL ค้นหาฟังก์ชันไม่พบ ใน PostgreSQL เวอร์ชันก่อน 13 ฟังก์ชัน gen_random_uuid() มาจากส่วนขยาย pgcrypto เท่านั้น ซึ่งเป็นส่วนขยายมาตรฐานของ PostgreSQL ที่ Supabase ติดตั้งในสคีมา extensions แทนสคีมาเริ่มต้น public ตั้งแต่ PostgreSQL 13 เป็นต้นไป ฟังก์ชันนี้ยังมีอยู่ในแกนหลักและค้นพบได้เสมอ ข้อผิดพลาดนี้จึงชี้ว่าใช้เวอร์ชันหลักที่เก่ากว่า GoTrue เรียก gen_random_uuid() โดยไม่ระบุสคีมา คาดว่า PostgreSQL จะค้นพบผ่านเส้นทางค้นหา แต่กลับหาไม่พบ
search_path ทำอะไร
PostgreSQL ค้นหาชื่อฟังก์ชันและตารางที่ไม่ระบุสคีมาโดยไล่ค้นสคีมาตามลำดับ ค่าเริ่มต้นคือ "$user", public: เริ่มจากสคีมาที่ชื่อเดียวกับผู้ใช้ฐานข้อมูลปัจจุบัน แล้วค้นใน public หากฟังก์ชันอยู่ในสคีมาอื่นและไม่ได้ระบุชื่อสคีมาอย่างชัดเจน โดยควรเรียก extensions.gen_random_uuid() แทน gen_random_uuid() PostgreSQL จะคืนข้อผิดพลาด “does not exist” ไม่ว่าฟังก์ชันนั้นจะมีอยู่จริงหรือไม่
Supabase แบบที่ผู้ให้บริการดูแลระบบให้ตั้งค่า search_path ของ PostgreSQL ให้รวม extensions, auth และ public การตั้งค่านี้มีเอกสารในฐานโค้ด Supabase แต่ไม่ได้แสดงเด่นชัดในคู่มือติดตั้งและดูแลระบบเอง บริการที่ผู้ให้บริการดูแลระบบให้ใช้ค่านี้โดยอัตโนมัติ ส่วน Docker ที่ติดตั้งเองขึ้นกับตัวแปรสภาพแวดล้อมและสคริปต์เริ่มต้นที่นำมาใช้ ระบบของเราไม่ได้ตั้งไว้ทั้งสองทาง
เหตุใดมือถือจึงเสีย แต่เว็บยังใช้ได้
คำอธิบายจากล็อกยังไม่ตอบทันทีว่าเหตุใดการยืนยันตัวตนบนเว็บจึงใช้ได้แต่มือถือใช้ไม่ได้ คำตอบอยู่ที่ความต่างของขั้นตอน OAuth ระหว่างสภาพแวดล้อม
เว็บแอปที่ใช้ Supabase OAuth ใช้ implicit flow เป็นค่าเริ่มต้น: เซิร์ฟเวอร์กำหนดสิทธิ์คืนโทเคนโดยตรงในส่วน fragment ของ URL หลัง callback ของ OAuth ไคลเอนต์ดึงโทเคนจาก URL และเก็บไว้ แล้วสร้างเซสชันได้โดยไม่ต้องแลกเปลี่ยนโทเคนแยกอีกขั้น เส้นทางโค้ดนี้บังเอิญไม่ได้เรียก gen_random_uuid()
แอปมือถือที่ทำตามแนวปฏิบัติด้านความปลอดภัยปัจจุบันใช้ PKCE หรือ Proof Key for Code Exchange ใน PKCE ไคลเอนต์สร้าง code verifier แบบสุ่ม นำไปแฮชเป็น code challenge และส่ง challenge ไปยังเซิร์ฟเวอร์กำหนดสิทธิ์ หลัง callback ไคลเอนต์ส่ง code verifier เดิมไปยัง endpoint โทเคน ซึ่งตรวจสอบก่อนออกโทเคน โฟลว์ PKCE นี้คือเส้นทางโค้ดที่ล้มเหลว GoTrue เรียก gen_random_uuid() ขณะสร้างสถานะ OAuth สำหรับ PKCE แต่สคีมา extensions ไม่อยู่ในเส้นทางค้นหา
implicit flow บนเว็บข้ามเส้นทางโค้ดนี้ แต่ PKCE บนมือถือไม่ข้าม ความต่างเล็กน้อยในการใช้ OAuth เผยข้อผิดพลาดการตั้งค่าที่มีอยู่ในระบบ Docker ตั้งแต่นำขึ้นใช้งาน เพียงแต่ไม่เคยผ่านเส้นทางนั้นจนไคลเอนต์มือถือพยายามใช้ PKCE
วิธีแก้
วิธีแก้ที่ถูกต้องคือให้ search_path ของ PostgreSQL รวมทุกสคีมาที่ GoTrue และ PostgREST พึ่งพา สำหรับ Docker Compose การตั้งในระดับฐานข้อมูลเป็นวิธีที่ตรงที่สุด เพราะคงค่าไว้ไม่ว่าคอนเทนเนอร์ใดจะเชื่อมต่อ:
-- Run once against the database
ALTER DATABASE postgres SET search_path TO extensions, auth, public, storage;
ALTER ROLE authenticator SET search_path TO extensions, auth, public, storage;
ALTER ROLE supabase_auth_admin SET search_path TO extensions, auth, public, storage;
อีกทางเลือกคือส่งค่าผ่านการตั้งค่าบริการ PostgreSQL ใน Docker Compose:
services:
db:
image: supabase/postgres:15.1.0.117
command:
- postgres
- -c
- search_path=extensions,auth,public,storage
เราใช้ทั้งสองวิธี หลังเริ่มคอนเทนเนอร์ GoTrue ใหม่ OAuth บนมือถือเสร็จสมบูรณ์โดยไม่มีข้อผิดพลาด Apple Sign-In ซึ่งเราลังเลที่จะทดสอบระหว่างไล่ปัญหา ก็ใช้ได้ทันทีเช่นกัน ยืนยันว่าปัญหาขึ้นกับขั้นตอนที่ใช้ ไม่ใช่ผู้ให้บริการ
บทเรียนเรื่องการแก้ปัญหาระบบที่ติดตั้งและดูแลเอง
เส้นทางการไล่ปัญหาจาก “การยืนยันตัวตนบนมือถือเสีย” ไปถึง “search_path ใน PostgreSQL ผิด” ใช้เวลานานกว่าที่ควร มีบทเรียนหลายข้อที่ควรเก็บไว้
ล็อกคอนเทนเนอร์คือหลักฐานจริง เราไล่สองสมมติฐานที่ผิดก่อนอ่านล็อก GoTrue ทั้งที่บันทึกที่เกี่ยวข้องรายการแรกมีข้อผิดพลาดตรงจุดอยู่แล้ว สำหรับระบบ Docker ที่ติดตั้งและดูแลเอง การอ่านล็อกควรเป็นขั้นแรก ไม่ใช่ทางเลือกสุดท้ายหลังลองทุกทฤษฎี
ความล้มเหลวเฉพาะแพลตฟอร์มใน backend ที่ใช้ร่วมกันมักบ่งชี้ความต่างของเส้นทางโค้ดฝั่งไคลเอนต์ ไม่ใช่ความต่างในการปฏิบัติต่อไคลเอนต์นั้น backend ไม่รู้ว่ากำลังให้บริการแอปมือถือหรือเบราว์เซอร์ แต่ทำตามเส้นทางโค้ดที่พารามิเตอร์คำขอกำหนด หากเข้าใจว่า PKCE เรียกเส้นทางใดต่างจาก implicit flow ก็จะชี้ไปที่สถานะของโฟลว์ PKCE ได้ทันที
บริการที่ผู้ให้บริการดูแลระบบให้กับบริการที่ติดตั้งเองไม่ได้มีการตั้งค่าเหมือนกัน Supabase Cloud ใช้รายละเอียดการตั้งค่าที่มีเอกสาร แต่ไม่ได้ใช้โดยอัตโนมัติในชุด Docker Compose เมื่อบางอย่างใช้ได้บน Supabase Cloud แต่เสียบนระบบติดตั้งเอง ความต่างมักอยู่ที่การตั้งค่าเหล่านี้ ระบบติดตามปัญหาของคลังโค้ดทางการสำหรับการติดตั้งเองมักเป็นวิธีเร็วที่สุดในการหาค่าที่ขาด
บั๊กที่ “กระทบแพลตฟอร์มเดียว” มักเป็นบั๊กที่มีมาตลอด แต่เพิ่งถูกเผยโดยไคลเอนต์ตัวแรกที่ใช้เส้นทางโค้ดเฉพาะ ในกรณีนี้ข้อผิดพลาด search_path มีอยู่ตั้งแต่วันที่นำระบบ Docker ขึ้นใช้งาน ต้องผ่านไปหลายเดือนและมีการใช้ OAuth บนมือถือจึงเห็นปัญหา