Skip to content

3.1.2 · GraphQL request pipeline

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

ทุก citation ในบทนี้ re-verify ตรงกับ vtrc-api บนดิสก์ (2026-07-13) ไม่มี drift


ภาพรวม 5 gates

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)                         │
│   lib/controllers/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                                  │
│   lib/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                                     │
│   lib/repositories/session/session.js:20-48  checkSession    │
│   ตรวจ role ต้องอยู่ใน accessRole[]                            │
│   ตรวจ Session.userSession ต้องมี jwtId ตรง                   │
│   ตรวจ client = device                                      │
└─────────────────────────────────────────────────────────────┘


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


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

Gate #1 — apiKey

vtrc-api/api/src/index.js:33-38

javascript
            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.1)
  • lookup ใช้ Array.filter — O(N) ต่อ request ที่ N เล็กไม่ใช่ปัญหา แต่เป็น code smell
  • ถ้าไม่ตรง → responError('ACCESS_KEY_INVALID') throw error ออกไป

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

vtrc-api/api/src/index.js:46-53

javascript
            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 มา


Gate #2 — JWT decode (lightweight)

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

vtrc-api/api/src/lib/controllers/auth/auth.js:23-41

javascript
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) verify จริงเลื่อนไปที่ @auth directive เฉพาะ field ที่ต้องการ

ผลข้างเคียง — context ไม่เคย reject request ที่ตรงนี้ ทุก public field ผ่านไปได้แม้ไม่มี token เลย


Apollo dispatch + @auth directive

ตัวอย่าง field ที่ติด directive

vtrc-api/api/src/typeDefs/authentication.js:57-57

javascript
    ) : String! @auth(accessRole: ["USER"])

directive รับ 2 argument — accessRole: [String] (role ที่ต้องการ) และ accessMenu: [String] (menu key เพิ่มเติม, optional) ตัว directive อยู่ที่ directives/auth.js ยาว 51 บรรทัด


Gate #3 — JWT verify

vtrc-api/api/src/directives/auth.js:23-31

javascript
      const payload = await decode(context.token, JWT_SECRET)

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

verifyToken อยู่ที่ lib/repositories/auth/auth.js:250-258

vtrc-api/api/src/lib/repositories/auth/auth.js:250-258

javascript
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

vtrc-api/api/src/directives/auth.js:33-40

javascript
      const error = verify.error
      if (error) {
        if (error.name === 'TokenExpiredError') {
          return responError('TOKEN_EXPIRED')
        } else {
          return responError('TOKEN_INVALID')
        }
      }

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

decodeToken ไม่ระบุ algorithm ให้ jwt.verify — jsonwebtoken ใช้ algorithm ที่อยู่ใน JWT header เอง เปิดช่อง "alg: none" attack (รายละเอียดใน Volume 12 Security)


Gate #4 — Session + role check

vtrc-api/api/src/directives/auth.js:43-43

javascript
      await checkSession(verify.data.userId, accessRole, payload.jti, context.device)

vtrc-api/api/src/lib/repositories/session/session.js:20-48

javascript
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 สิ่งที่เช็ค — Role membership (getCurrentRoleUser), Session exists (userSession JSON column ต้องมี jwtId ตรง), Device matches (Session.client ตรงกับ device)

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

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

Gate #5 — Menu access (ABAC)

vtrc-api/api/src/directives/auth.js:44-44

javascript
      await checkMenuAuth(verify.data.userId, accessMenu)

vtrc-api/api/src/lib/repositories/session/session.js:50-70

javascript
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 — lookup User ดึง role (JSON column) → lookup RoleAccess ทั้งหมดของ roleId ที่อยู่ใน user.role.accessRole → filter RoleAccess.nameEN ที่อยู่ใน accessMenu[]

Bug ที่สำคัญ

logic นี้มี dual-source-of-truth bug — getCurrentRoleUser (gate #4) ใช้ role จาก accessRole arg ที่ directive กำหนด แต่ checkMenuAuth (gate #5) ดึง user.role.accessRole ทั้งหมดของ user ไม่ใช่แค่ role ที่ผ่าน gate #4 — ทำให้เกิด cross-role escalation ได้ในทางทฤษฎี (ถ้า user มีหลาย role) รายละเอียดเต็มใน 3.1.3


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

vtrc-api/api/src/directives/auth.js:45-46

javascript
      const data = await resolve.apply(this, args);
      return data;

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


Error format — formatError

vtrc-api/api/src/index.js:61-64

javascript
    formatError: (err) => {
        console.log("err ->", err);
        return IS_PRODUCTION ? graphqlFormatError(err) : err
    },

ใน dev return error เต็มๆ รวม stack trace ใน prod ผ่าน graphqlFormatError ใน lib/responErrors.js เพื่อ map code → Thai message + ซ่อน stack ดู 3.1.9


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

vtrc-api/api/src/index.js:68-69

javascript
    playground: true,
    introspection: true,

schema ทั้งหมด (typeDefs 44 ไฟล์ ทุก mutation/query/type/input) เปิดเผยต่อ public ใครก็เปิด GraphQL endpoint ในเบราว์เซอร์แล้วเห็น playground พร้อม schema explorer ดู severity ใน 3.1.10


GraphQL upload + body limit

vtrc-api/api/src/index.js:25-25

javascript
app.use(servicePath, graphqlUploadExpress({ maxFileSize: 20000000, maxFiles: 100 }));

maxFileSize: 20 MB ต่อไฟล์, maxFiles: 100 ต่อ request และมี body limit ที่ Express

vtrc-api/api/src/index.js:87-93

javascript
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 body limit ค่อนข้างสูง — request ที่มี body ใหญ่กินได้ตามนั้นทุกครั้ง


CORS

vtrc-api/api/src/index.js:76-85

javascript
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) — แต่ key ชื่อ corss (typo) ถูก pass เข้า applyMiddleware ที่คีย์ที่ถูกต้องของ apollo-server-express คือ cors → config นี้ไม่ถูกใช้จริง
  • ต่อมามี app.use(cors()) (index.js:91) ที่เปิด default = allow ทุก origin — ทับ allowlist ด้านบน
  • ผลคือ CORS เปิดหมดจริง เป็น bug เงียบ

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

ตอน debug "request ถูก reject" — ดู code แล้วนึกย้อนกลับได้

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

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

ทั้ง context error handler และ formatError — production log มี error ปนอยู่จำนวนมาก

@auth directive รันทุก field

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

field ใหม่ที่ไม่ใส่ @auth(...) เป็น public ทันที

เคยมี bug ที่ลืมใส่ directive แล้ว expose ข้อมูล PII — ตรวจทุกครั้งก่อน merge


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

ไป 3.1.3 Authentication & session internals