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 / centralizeGate #1 — apiKey
vtrc-api/api/src/index.js:33-38
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
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
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
) : 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
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
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
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
await checkSession(verify.data.userId, accessRole, payload.jti, context.device)vtrc-api/api/src/lib/repositories/session/session.js:20-48
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
Op.likeบน JSON column (session.js:31) — LIKE%{jwtid}%เปิดช่อง substring collisiongetCurrentRoleUsersignature mismatch — caller ส่งdeviceเป็น arg ที่ 3 แต่ function รับแค่(userId, accessRole)→deviceถูก dropped โดย JavaScript default (รายละเอียดใน 3.1.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
await checkMenuAuth(verify.data.userId, accessMenu)vtrc-api/api/src/lib/repositories/session/session.js:50-70
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
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
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
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
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
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
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 แล้วนึกย้อนกลับได้
| Code | Gate ที่ติด |
|---|---|
ACCESS_KEY_INVALID | Gate #1 |
TOKEN_EXPIRED | Gate #3 |
TOKEN_INVALID | Gate #2, #3 หรือ Gate #4/5 |
CANNOT_ACCESS | Gate #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