Skip to content

3.2.4 · @auth directive + bypass

บทนี้เจาะ @auth directive ของ cu-central-api ซึ่งมี complexity ที่ vtrc-api ไม่มี — accessLevel + level system, ignoreAuth bypass ที่ใช้ apiKey แทน JWT, และ requires role check ที่ commented out


Directive declaration

import { gql } from 'apollo-server-express'

export default gql`
  directive @auth(requires: [String], accessLevel: Int, ignoreAuth: Boolean) on FIELD_DEFINITION

3 arguments

ArgTypeหน้าที่สถานะ
requires[String]role names ที่จำเป็นcommented out ใน implementation
accessLevelIntAPI key level ขั้นต่ำ (1=general, 4=admin)ใช้งาน
ignoreAuthBooleanข้าม JWT verification — ใช้ apiKey อย่างเดียวใช้งาน

Implementation

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition (field) {
    const { resolve = defaultFieldResolver } = field
    const { requires, accessLevel, ignoreAuth } = this.args

    field.resolve = async function (...args) {
      const [, , context] = args
      const { apikey } = context.headers
      const clientAppInfo = context.clientAppInfo

      let currentLevel = null
      let appId = null
      if (typeof clientAppInfo !== 'undefined') {
        currentLevel = clientAppInfo.level
        appId = clientAppInfo.appId

        // Check API key access level
        if (accessLevel > 0) {
          if (clientAppInfo.level < accessLevel) {
            log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'This level cant access.', { token: context.token, requiredLevel: accessLevel, currentLevel, appId }))
            throw new AuthenticationError('NOT_AUTHORIZED')
          }
        }

        // Ignore verify token
        if (ignoreAuth) {
          return resolve.apply(this, args)
        } else {
          // ** Not ignore then verify token ** //
          // Verify token
          const verifyProfile = await verifyToken(context.token)
          if (!verifyProfile.error) {
            // Verify logged in
            const profile = verifyProfile.data
            const loggingIn = await isLoggedIn(profile)
            if (loggingIn) {
              // if (requires) {
              //   if (!hasRole(requires, profile.profile.roles)) {
              //     // log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'Role not authorize to access.', { profileUid: profile.profile.profileUid, token: context.token, requiredRole: requires, userRole: profile.profile.roles }))
              //     throw new AuthenticationError('NOT_AUTHORIZED')
              //   }
              // }

              return resolve.apply(this, args)
            } else {
              throw new AuthenticationError('You must be logged in to access this service.')
            }
          } else {
            const error = verifyProfile.error
            if (error.name === 'TokenExpiredError') {
              throw new ApolloError(error.message, error.name, { expiredAt: error.expiredAt })
            } else {
              throw new ApolloError(error.message, error.name)
            }
          }
        }
      } else {
        log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'Invalid API KEY.', { apikey }))
        throw new AuthenticationError('NOT_AUTHORIZED')
      }
    }
  }
}

flow มี 5 gate

  1. apiKey check (auth.js:24) — clientAppInfo ต้องไม่ undefined (มาจาก checkApiKey(req.headers.apikey) ใน context builder)
  2. accessLevel check (auth.js:29-33) — เทียบ clientAppInfo.level กับ directive arg
  3. ignoreAuth bypass (auth.js:37-38) — ถ้า true ข้ามไป resolve โดยตรง ข้าม JWT + session
  4. JWT verify + session check (auth.js:42-47) — เกิดเฉพาะถ้าไม่ใช่ bypass
  5. requires checkcommented out ทั้ง block (auth.js:48-53) RBAC ไม่ทำงาน

Hardcoded apiKey table

export const checkApiKey = (apiKey) => {
  if (typeof apiKey === 'undefined') {
    return apiKey
  }
  // access key level 1 is general, 4 is able to search users.
  const allowedApiKeys = [
    {
      apiKey: 'OGJlYTEzOTBjMzk3NjlmYWQ1ZTcxZDZhN2NhZWMyYTk3YTQ2NGIzMDhiMGM4NmRjM2NlNjZkM2I2ZDIyOGQyMQ',
      appId: 'acwfa3t-wafbAS-fawkgt3948yf-egrhe3fwe',
      appName: 'VTRC_APP',
      level: 1
    },
    {
      apiKey: '562B132B1E87695A052717C20336CDA57FBC4C5B19EF31356CC07C5A43656DE47DEFE452D241C3F443DE54',
      appId: '7d962c5b-68d1-4a8f-849d-4d617711d94b',
      appName: 'VTRC_ADMIN_APP',
      level: 4
    }
  ]

  return allowedApiKeys.find(key => key.apiKey === apiKey)
}

🔴 Critical (SEC-CU-02) — 2 apiKeys commit เป็น literal ใน code

KeyAppLevel
OGJlYTEzOTBj...VTRC_APP1 (general)
562B132B1E87...VTRC_ADMIN_APP4 (admin — search users ได้)

ต่างจาก vtrc-api ที่เก็บใน config.js (API_DEVICE_KEY) — cu-central-api hardcode ใน controller เลย

level system

Levelความหมายตัวอย่าง field
1general (VTRC_APP)query ข้อมูลตัวเอง
4admin (VTRC_ADMIN_APP)searchUser — search employee อื่น

@auth(accessLevel: 4) บน field บังคับว่าต้องใช้ admin apiKey


ignoreAuth bypass pattern

    ): SearchUserResponse! @auth(accessLevel: 4, ignoreAuth: true)
  extend type Query {
    "Fetch personal profile by filter."
    searchUser(
      empCode: String!
      firstName: String!
      lastName: String!
      identityCard: String!
      userIds: [ID!]
    ): SearchUserResponse! @auth(accessLevel: 4, ignoreAuth: true)
  }

การใช้งาน @auth(accessLevel: 4, ignoreAuth: true) หมายถึง

  • ต้องมี admin apiKey (level 4)
  • แต่ข้าม JWT verification + session check ทั้งหมด

คือ "apiKey อย่างเดียวพอ" — ไม่ต้อง login เป็น user

Use case ที่น่าจะเป็น

endpoint นี้อาจออกแบบมาสำหรับ service-to-service call — admin service ที่ถือ apiKey แล้วอยาก search user โดยไม่ต้อง maintain session ของตัวเอง

ความเสี่ยง (SEC-CU-05)

ถ้า apiKey level 4 รั่ว (และมัน commit อยู่ใน code อยู่แล้วใน auth.controller.js:266) — attacker เรียก searchUser ได้ทันทีโดยไม่ต้องผ่านขั้นตอน login ใด ๆ


requires — commented out

              // if (requires) {
              //   if (!hasRole(requires, profile.profile.roles)) {
              //     // log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'Role not authorize to access.', { profileUid: profile.profile.profileUid, token: context.token, requiredRole: requires, userRole: profile.profile.roles }))
              //     throw new AuthenticationError('NOT_AUTHORIZED')
              //   }
              // }

RBAC role check ที่ประกาศใน schema (requires: [String]) ไม่ถูก implement — ทุก field ที่ใช้ @auth(requires: [...]) จะผ่านเสมอ (QUAL-CU-01)

Impact

  • ไม่มี RBAC จริงในระบบ
  • การ authorize พึ่ง apiKey level + JWT verification เท่านั้น
  • ถ้ามีคนวาง @auth(requires: ['ADMIN']) เพื่อจำกัด field — มันไม่ทำงาน

Fix ที่ควรทำ

  • ลบ requires arg ออกจาก directive declaration (clean up schema)
  • หรือ implement ให้ครบ + เพิ่ม test matrix

Context builder

  context: async ({ req, res }) => {
    // If correct then return client info object otherwise return undefined.
    const clientAppInfo = checkApiKey(req.headers.apikey)

    // get the user token from the headers
    const tokenWithBearer = req.headers.authorization || ''
    const token = tokenWithBearer.split(' ')[1]

    // try to retrieve a user with the token
    let data = await decodeJwtToken(token)
    data = data.data
    let userData = null
    // Get Session data
    if (data.userCredential) {
      userData = await getSessionFromTokenCredential(data.userCredential)
    }

    // Verify
    const verify = await verifyToken(token)
    let isVerify = false
    if (typeof verify.data !== 'undefined') {
      if (typeof verify.data.verify !== 'undefined') {
        isVerify = verify.data.verify
      }
    } else {
      isVerify = verify.error === null
    }

    // add the user to the context
    return {
      token,
      isVerify, // (Boolean) Data is verified
      userData, // Should be access to data only
      headers: req.headers,
      clientAppInfo
    }
  },

context builder ส่ง clientAppInfo ให้ directive — แต่ ไม่ throw ถ้า apiKey invalid — gate นั้นอยู่ที่ directive เอง

Bug — context ไม่ fail-fast

ถ้า apiKey invalid (clientAppInfo === undefined) context ยังคง return ปกติ — directive ถึงจะ throw ทีหลัง ทำให้ logging ของ request ที่ invalid ไม่ครบถ้วน

Bug — decode 2 ครั้ง

decodeJwtToken(token) (บรรทัด 45) และ verifyToken(token) (บรรทัด 54) ทำงานซ้อนกัน — decode ครั้งแรกไม่ verify, ครั้งที่สอง verify ทั้งคู่อ่าน token จาก string เดียวกัน เป็น overhead เล็กน้อย


JWT verification

ใน directive (gate 4 หลัง ignoreAuth bypass)

          const verifyProfile = await verifyToken(context.token)
export const verifyToken = (token, option = {}) => {
  try {
    const decodedVerify = decodeToken(token, true, option)
    return { data: decodedVerify, error: null }
  } catch (e) {
    log.warning(log.logTextFormat('TOKEN_VERIFY_FAILED', 'Method:verifyToken', 'WARNING', 'Verify token has failed.', { error: e, token, option }))
    return { data: {}, error: e }
  }
}
const decodeToken = (token, verify = true, verifyOption = {}) => {
  // ...
  if (token) {
    if (verify) {
      let verifyDecoded
      if (Object.keys(verifyOption).length > 0) {
        verifyDecoded = jwt.verify(token, getPrivayeKey(), verifyOption)
      } else {
        verifyDecoded = jwt.verify(token, getPrivayeKey())
      }

ไม่มี algorithms: ['HS256'] allow-list — เปิดช่อง alg-confusion attack ในทางทฤษฎี (เหมือน vtrc-api บท 9.3)


Session check (isLoggedIn)

หลัง JWT verify แล้ว directive ตรวจ session ใน Redis

            const loggingIn = await isLoggedIn(profile)

isLoggedIn อยู่ใน lib/authenAndAuthorize — ใช้ getSessionFromTokenCredential ที่ดูใน 3.2.3

ถ้า session ไม่อยู่ → throw AuthenticationError('You must be logged in...')

Session lookup ไม่มี JSON scan

ต่างจาก vtrc-api ที่ใช้ LIKE '%jwtid%' scan JSON blob — cu-central-api lookup ผ่าน key pattern SESSION:{redisPrefix}:{userUid}_{sessionId} โดยตรง — ทำงานเร็วกว่า


เปรียบเทียบกับ vtrc-api @auth

Aspectvtrc-apicu-central-api
ArgsaccessRole, accessMenurequires, accessLevel, ignoreAuth
apiKey ที่ใช้จาก config (7 prod keys)hardcoded 2 keys ใน controller
RBACgetCurrentRoleUser + checkMenuAuth (มี bug CORR-4)requires commented out — ไม่ทำงาน
Bypassไม่มี🔴 ignoreAuth: true สำหรับ apiKey-only
Session lookupLIKE '%jwtid%' JSON scanRedis key pattern (ดีกว่า)
Default-denyไม่มี (field ที่ลืม @auth = public)ไม่มี (เหมือนกัน)
Error handlinggenericแยก TokenExpiredError vs JsonWebTokenError (auth.js:61-67)

ตารางสรุปช่องโหว่ในบทนี้

#ปัญหาSeverityIDFile:line
1apiKeys hardcoded ใน code🔴 CriticalSEC-CU-02cu-central-api/main-api/src/lib/auth/auth.controller.js:258-271
2ignoreAuth bypass🔴 CriticalSEC-CU-05cu-central-api/main-api/src/directives/auth.js:37-38 + cu-central-api/main-api/src/typeDefs/searchProfile.js:12
3requires commented out🟡 HighQUAL-CU-01cu-central-api/main-api/src/directives/auth.js:48-53
4ไม่มี algorithms allow-list🟡 Highcu-central-api/main-api/src/lib/jwtToken.js:143-145
5ไม่มี default-deny🟡 Highdirective เฉพาะ field ที่ annotated
6/auth/signinbypass endpoint🔴 CriticalSEC-CU-04cu-central-api/main-api/src/routes.js:31-37
7context ไม่ fail-fast ถ้า apiKey invalid🟢 MediumQUAL-CU-04cu-central-api/main-api/src/index.js:38
8decode token 2 ครั้ง (overhead)🟢 MediumPERF-CU-02cu-central-api/main-api/src/index.js:45,54

อ่านต่อ → 3.2.5 Multi-tenant SourceDB sharding