Skip to content

3.2 · GraphQL request pipeline

บทนี้เล่าว่า request หนึ่งตัวเดินทางจาก POST /api/graphql จนถึง resolver และกลับ — ผ่านกี่ gate แต่ละ gate ทำอะไร และจุดไหนที่มักเป็นปัญหา

ถ้า debug "ทำไม request ถูก reject ด้วย TOKEN_INVALID" หรือ "ทำไม cache ไม่ทำงาน" — เริ่มที่บทนี้


ภาพรวม 5 gates

ทุก GraphQL request ผ่าน gate เรียงตามลำดับนี้ ถ้า gate ไหน fail → reject ทันที ไม่เข้า gate ถัดไป

Browser POST /api/graphql
       │  Headers: apiKey: <uuid>
       │           Authorization: Bearer <jwt>

┌─────────────────────────────────────────────────────────────┐
│ Gate #1 · apiKey                                            │
│   index.js:33-38  context()                                 │
│   ตรวจ apiKey ต้องตรง API_DEVICE_KEY[API_ACCESS]            │
│   ถ้าไม่ตรง → responError('ACCESS_KEY_INVALID')              │
└─────────────────────────────────────────────────────────────┘
       │  resolve APP_TYPE จาก mapKey[0].APP_TYPE

┌─────────────────────────────────────────────────────────────┐
│ Gate #2 · JWT decode (lightweight)                          │
│   index.js:41-44  getPayLoad(token)                         │
│   auth/auth.js:23-41  jwt.decode (ไม่ใช่ verify)             │
│   ดึง uid + ใส่ใน context                                     │
│   ถ้า token == null → loggedIn: false (ไม่ reject)            │
└─────────────────────────────────────────────────────────────┘
       │  context = { token, userId, loggedIn, device, appId, headers }

┌─────────────────────────────────────────────────────────────┐
│ Apollo dispatch → resolver                                  │
│   ถ้า field ติด @auth → gate #3-5                            │
│   ถ้าไม่ติด → วิ่งเข้า resolver ทันที                            │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Gate #3 · JWT verify                                        │
│   directives/auth.js:23-31                                  │
│   repositories/auth/auth.js:250-258  verifyToken             │
│   jwt.verify + expiresIn + issuer + jwtId                   │
│   TokenExpiredError → 'TOKEN_EXPIRED'                       │
│   อื่นๆ → 'TOKEN_INVALID'                                    │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Gate #4 · Session + role check                              │
│   directives/auth.js:43                                     │
│   repositories/session/session.js:20-48  checkSession        │
│   ตรวจ role ต้องอยู่ใน accessRole[]                            │
│   ตรวจ Session.userSession ต้องมี jwtId ตรง                   │
│   ตรวจ client = device                                      │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Gate #5 · Menu access (ABAC)                                │
│   directives/auth.js:44                                     │
│   repositories/session/session.js:50-70  checkMenuAuth       │
│   ถ้า accessMenu ไม่กำหนด → ข้าม                              │
│   ถ้ากำหนด → user ต้องมี role ที่มี RoleAccess.nameEN ตรง       │
└─────────────────────────────────────────────────────────────┘


   resolver (resolvers/*.js) → controller → repository → Models / centralize

Gate #1 — apiKey (gateway class check)

ก่อนที่จะถอด JWT หรือ run resolver — ต้องผ่าน apiKey check ก่อน

33:38:vtrc-api/api/src/index.js
            const apiKey = req.headers.apikey
            const mapKey = await allowedKeys.filter(value => value.key === apiKey)

            if (mapKey.length === 0) {
                responError('ACCESS_KEY_INVALID')
            }
  • allowedKeys คือ array จาก API_DEVICE_KEY[API_ACCESS] (ดู บท 3.1) — มี 7 ตัวใน prod, 10 ตัวใน uat
  • การ lookup ใช้ Array.filter ทำให้เป็น O(N) ต่อ request — ที่ N=7 ไม่ใช่ปัญหา แต่เป็น code smell
  • ถ้าไม่ตรง → responError('ACCESS_KEY_INVALID') ซึ่ง throw error ออกไป (ดูใน lib/responErrors.js)

สิ่งที่ gate นี้ resolve

ถ้าผ่าน → context จะมี device (APP_TYPE) และ appId ให้ resolver ใช้ต่อ

46:53:vtrc-api/api/src/index.js
            return {
                token,
                userId: user.uid,
                loggedIn,
                device: mapKey[0].APP_TYPE,
                appId: mapKey[0].id,
                headers: req.headers
            }

device จะถูกใช้ใน gate #4 เพื่อ enforce ว่า Session.client ต้องตรงกับ device class ที่ login มา — เช่น token ที่ login ผ่าน WEB ใช้กับ WEB_ADMIN request ไม่ได้


Gate #2 — JWT decode (lightweight)

ใน context() จะ decode JWT เพื่อเอา uid ไว้ใน context — แต่ใช้ jwt.decode ไม่ใช่ jwt.verify ตรงนี้

23:41:vtrc-api/api/src/lib/controllers/auth/auth.js
export const getPayLoad = async token => {
  try {
    // Verify JWT Token
    const payload = await decode(token, JWT_SECRET)
    var setDataPayload
    if (payload == null) {
      setDataPayload = {
        loggedIn: false, payload: { uid: null }
      }
    } else {
      setDataPayload = {
        loggedIn: true, payload
      }
    }
    return setDataPayload
  } catch (err) {
    return { loggedIn: false, payload: { uid: null } }
  }
};

ทำไม decode แทน verify ที่ context

เหตุผลคือ resolver บางตัวเป็น public (login, generateCaptcha, refreshToken) — gate #3-5 ที่ทำ verify จริงจะรันเฉพาะ field ที่ติด @auth directive

การ decode ที่ context แค่ใส่ loggedIn flag ให้ resolver รู้ว่า user login อยู่หรือเปล่า — verify จริงเลื่อนไปที่ directive

ผลข้างเคียงที่ต้องรู้

  • ถ้า JWT หมดอายุ → decode สำเร็จ แต่ resolver ที่ติด @auth จะ fail ที่ gate #3
  • ถ้า JWT sign ผิด → decode คืน nullloggedIn: false → resolver ที่ติด @auth จะ fail ที่ TOKEN_INVALID
  • context ไม่เคย reject request ที่ตรงนี้ — ทุก public field จะผ่านไปได้แม้จะไม่มี token เลย

Apollo dispatch + @auth directive

หลังจาก context สร้างเสร็จ Apollo จะ dispatch request ไปยัง resolver ที่ field นั้น

ถ้า field ติด @auth directive — directive จะ wrap resolver function ด้วย gate #3-5 ก่อน

ตัวอย่าง field ที่ติด directive (typeDefs/authentication.js:57)

57:57:vtrc-api/api/src/typeDefs/authentication.js
    ) : String! @auth(accessRole: ["USER"])

directive รับ 2 argument

  • accessRole: [String] — role ที่ต้องการ (USER, SUPER_ADMIN, ADMIN, STAFF หรือผสม)
  • accessMenu: [String] — menu key ถ้าต้องการเช็คเพิ่ม (optional)

directive เองอยู่ที่ directives/auth.js ยาว 51 บรรทัด เป็นหัวใจของระบบ RBAC


Gate #3 — JWT verify

23:31:vtrc-api/api/src/directives/auth.js
      const payload = await decode(context.token, JWT_SECRET)

      // Verify token
      const verify = verifyToken(context.token,
        {
          expiresIn: JWT_EXP,
          issuer: JWT_ISSUER,
          jwtId: payload.jti
        })

ในที่นี้ใช้ decode อีกครั้งเพื่อดึง jti (JWT ID) ออกมาก่อน แล้วค่อย verify โดยใช้ jti เป็น options

verifyToken แท้จริงแล้วอยู่ที่ repositories/auth/auth.js:250-258

250:258:vtrc-api/api/src/lib/repositories/auth/auth.js
export const verifyToken = (token, option = {}) => {
    try {
        const decodedVerify = decodeToken(token, option)
        return { data: decodedVerify, error: null }
    } catch (e) {
        return { data: {}, error: e }
    }
}

ถ้า verify fail — directive จะสลับ error type ตาม flavor ของ error

33:40:vtrc-api/api/src/directives/auth.js
      const error = verify.error
      if (error) {
        if (error.name === 'TokenExpiredError') {
          return responError('TOKEN_EXPIRED')
        } else {
          return responError('TOKEN_INVALID')
        }
      }

frontend (ดู vtrc-web/src/config/apollo.js:74) intercept TOKEN_EXPIRED แล้วเรียก refreshToken mutation ผ่าน side-channel apolloFetch ไม่ใช่ผ่าน Apollo client หลัก — เพราะ errorLink ทำงานอยู่ใน client จะวนลูป

ความผิดพลาดที่ต้องรู้

decodeToken ไม่ได้ระบุ algorithm ให้ jwt.verify — jsonwebtoken จะใช้ algorithm ที่อยู่ใน JWT header ทำให้เปิดช่อง "alg: none" attack ถ้า attacker forge JWT ที่ระบุ alg: none ใน header ระบบจะ verify ด้วย algorithm ที่ไม่มีการตรวจ signature

จะอธิบายเต็มใน Volume 10 Security


Gate #4 — Session + role check

หลัง verify token ผ่าน directive จะเรียก checkSession ที่ repositories/session/session.js:20

43:43:vtrc-api/api/src/directives/auth.js
      await checkSession(verify.data.userId, accessRole, payload.jti, context.device)
20:48:vtrc-api/api/src/lib/repositories/session/session.js
export const checkSession = async (userId, accessRole, jwtid, device) => {
  //Check access role
  const currentUserRole = await getCurrentRoleUser(userId, accessRole, device)
  if (currentUserRole.length <= 0) {
    return responError('CANNOT_ACCESS')
  }
  //Check Session
  var sessionResult = await Models.Session.findAll({
    where: {
      userId: userId,
      userSession: {
        [Op.like]: `%${jwtid}%`,
      },
      client: device
    }
  })

3 สิ่งที่เช็คในนี้

  1. Role membershipgetCurrentRoleUser ดูว่า user มี role ที่ตรงกับ accessRole[] ไหม
  2. Session existsSession.userId = userId และ userSession (JSON column) ต้องมี jwtId ที่ตรงกับ JWT
  3. Device matchesSession.client ต้องตรงกับ device (APP_TYPE ที่ resolve จาก apiKey)

ถ้าทั้งสามผ่าน → session ถือว่า valid

Bug ที่ต้องระวังใน checkSession

  1. Op.like บน JSON column (:31) — session lookup ใช้ LIKE %${jwtid}% ถ้า jwtid ของ session หนึ่งเป็น substring ของ session อื่นจะ match ผิดตัว เป็น substring collision ที่หายากแต่เป็นไปได้
  2. getCurrentRoleUser signature mismatch — caller ส่ง device เป็น arg ที่ 3 แต่ function signature รับแค่ (userId, accessRole)device ถูก dropped โดย default (จะกล่าวถึงใน บท 3.3)
  3. transaction arg หายModels.Session.update(args, { where }, { transaction: t }) ผ่าน { transaction } เป็น arg ที่ 3 ที่ Sequelize ไม่รับ → ไม่ใช่ transaction จริง

Gate #5 — Menu access (ABAC)

ถ้า @auth(accessMenu: [...]) กำหนดไว้ → directive จะเรียก checkMenuAuth

44:44:vtrc-api/api/src/directives/auth.js
      await checkMenuAuth(verify.data.userId, accessMenu)
50:70:vtrc-api/api/src/lib/repositories/session/session.js
export const checkMenuAuth = async (userId, accessMenu) => {
  try {
    if (!accessMenu) { return }
    let userResult = await Models.User.findAll({ where: { userId: userId } })
    const accessRole = await RoleAccess.findByConditions({ where: { roleId: userResult[0].role.accessRole } })
    const accessableMenu = accessRole.filter(role => accessMenu.includes(role.nameEN))

algorithm

  1. lookup User เพื่อดึง role (JSON column)
  2. lookup RoleAccess ทั้งหมดของ roleId ที่อยู่ใน user.role.accessRole
  3. filter RoleAccess.nameEN ที่อยู่ใน accessMenu[]

ถ้า user มี role อย่างน้อย 1 role ที่มี menu ที่กำหนด → ผ่าน

Bug ที่สำคัญ

audit doc (Phase 4) ระบุว่า logic นี้มี dual-source-of-truth bug — getCurrentRoleUser ใช้ Role.roleName แต่ checkMenuAuth ใช้ user.role.accessRole (ซึ่งเป็น JSON ใน User ไม่ใช่ Role) รายละเอียดและ exploit scenario อยู่ใน บท 3.3 และ Volume 10 Security


หลัง gate #5 — เข้า resolver

เมื่อผ่าน 5 gate หมดแล้ว resolver function ที่ wrap ไว้จะถูกเรียก

45:46:vtrc-api/api/src/directives/auth.js
      const data = await resolve.apply(this, args);
      return data;

resolve คือ resolver function จริงที่อยู่ใน resolvers/*.js — ส่วนใหญ่เป็น 1-line ที่ delegate ไป controller

32:34:vtrc-api/api/src/resolvers/authentication.js
        login: async (root, { lsid, password, clientInfo, apiKey, organization }, context, info) => {
            return auth.login(lsid, password, clientInfo, apiKey, organization)
        },

จาก controller → repository → Models / centralize — ดู บท 3.6 สำหรับรายละเอียด controller layer


Error format — formatError

ก่อน response ออกไป Apollo จะผ่าน formatError callback (index.js:61-64)

61:64:vtrc-api/api/src/index.js
    formatError: (err) => {
        console.log("err ->", err);
        return IS_PRODUCTION ? graphqlFormatError(err) : err
    },
  • ใน dev (!IS_PRODUCTION) → return error เต็มๆ รวม stack trace
  • ใน prod → ผ่าน graphqlFormatError ใน lib/responErrors.js เพื่อ map code → Thai message + ซ่อน stack

lib/responErrors.js (14 KB) เป็น lookup table ของ error code ~80 ตัว → ข้อความไทย — ดู บท 3.9 สำหรับรายละเอียด


Playground + introspection — เปิดทั้ง prod

68:69:vtrc-api/api/src/index.js
    playground: true,
    introspection: true,

อันนี้เป็น security concern จริง — schema ทั้งหมด (typeDefs 43 ไฟล์, ทุก mutation/query/type/input) เปิดเผยต่อ public ใครก็เปิด https://vtrcapi.redcross.or.th/api/graphql ในเบราว์เซอร์แล้วเห็น playground พร้อม schema explorer

เป็น severity S-? ใน บท 3.10 — ปกติแนะนำปิดทั้งสองใน prod แต่ VTRC เปิดทั้งคู่ (อาจจะตั้งใจเพื่อให้ partner debug ได้ แต่ควรตั้ง whitelist IP แทน)


GraphQL upload + body limit

ก่อน Apollo middleware จะมี graphqlUploadExpress สำหรับ multipart upload

25:25:vtrc-api/api/src/index.js
app.use(servicePath, graphqlUploadExpress({ maxFileSize: 20000000, maxFiles: 100 }));
  • maxFileSize: 20 MB ต่อไฟล์
  • maxFiles: 100 ต่อ request (เหมาะกับ Excel bulk upload 100 files)

และมี body limit ที่ Express

87:93:vtrc-api/api/src/index.js
server.applyMiddleware({ app, path: servicePath, bodyParserConfig: { limit: '50mb' }, corss })

app.disable('x-powered-by')

app.use(cors())
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));

50 MB ค่อนข้างสูง — ถ้า attack ยิง GraphQL mutation ที่มี body 50 MB ทุก request ก็กิน bandwidth ได้


CORS

76:85:vtrc-api/api/src/index.js
const corss = {
    origin: ['https://vtrc.redcross.or.th',
        'https://vtrcbackoffice.redcross.or.th',
        'https://uat-vtrc.redcross.or.th',
        'https://uat-vtrcbackoffice.redcross.or.th',
        'http://localhost:3000'],
    // origin: "*",
    methods: ['GET', 'POST'],
    allowedHeaders: ['Content-Type', 'authorization', 'apiKey'],
}
  • allowlist 5 origins (4 prod/uat + localhost)
  • แต่ต่อมามี app.use(cors()) อีกที (:91) ที่เปิด default = allow ทุก origin — แทนที่ allowlist ด้านบน
  • ผลคือ CORS เปิดหมดจริง เพราะบรรทัด :91 ทับ config ที่ส่งไปกับ applyMiddleware

เป็น bug เงียบ — config ที่เห็นใน corss object ไม่ได้ถูกใช้จริง


เคล็ดลับตอนทำงานจริง

ตอน debug "request ถูก reject" — ดู code ก่อน

responError(code) ส่ง error code เดียวออกไป — ดู code แล้วนึกย้อนกลับได้ว่าติด gate ไหน

CodeGate ที่ติด
ACCESS_KEY_INVALIDGate #1
TOKEN_EXPIREDGate #3
TOKEN_INVALIDGate #2, #3 หรือ Gate #4 (session/menu — session.js เรียก responError('TOKEN_INVALID'))
CANNOT_ACCESSGate #4 (session.js เมื่อ session/role ตรวจไม่ผ่าน)
ข้อความไทย "คุณไม่สิทธิดำเนินการ"Gate #5 (menu ไม่ตรง — มักมากับ TOKEN_INVALID)

console.log("err ->", err) จะอยู่ใน log ทุกครั้ง

ทั้งใน context error handler (index.js:55) และ formatError (index.js:62) — production log เต็มไปด้วย error ซึ่งอาจจะดี (debug ง่าย) หรือไม่ดี (sensitive data leak)

อย่าลืมว่า @auth directive รันทุก field

ถ้า schema มี nested type ที่แต่ละ field ติด @auth — resolver แต่ละ field จะผ่าน gate #3-5 ใหม่ ทำให้เกิด N+1 query ที่ database (session lookup + role lookup × N)

ถ้าจะเพิ่ม public field ใหม่ — อย่าลืมว่าไม่ติด @auth

ในไฟล์ typeDefs ใหม่ ถ้าไม่ใส่ @auth(...) directive → field นั้นเป็น public ทันที ไม่ต้อง login ก็เรียกได้ — เคยมี bug ที่ลืมใส่แล้ว expose ข้อมูล PII


ขั้นตอนถัดไป

ไป บท 3.3 Authentication & session internals